Merge feature/native-typed-json: native-typed JSON for List attribute values + data normalization

List values now encode as native-typed JSON ([10,20], [true,false], ISO dates;
strings stay quoted) via AttributeValueCodec; Decode reads both native and the
earlier array-of-strings form for every element type. Already-persisted old-form
data is normalized on the fly: idempotent central startup normalizer
(ListValueNormalizer), active site-SQLite normalization on InstanceActor
override-load, and normalize-on-import in the bundle importer. Instance-override
writes now stamp ElementDataType (#93/M3). Full solution 0/0; feature-targeted
tests green. Plan: docs/plans/2026-06-16-native-typed-json.md.
This commit is contained in:
Joseph Doherty
2026-06-16 18:36:07 -04:00
17 changed files with 1289 additions and 18 deletions
@@ -0,0 +1,78 @@
# Native-Typed JSON for List Attribute Values — Design
**Date:** 2026-06-16
**Status:** Implemented (NJ-1 … NJ-6, branch `feature/native-typed-json`) — full solution builds 0/0; feature-targeted tests green across Commons, TemplateEngine, ConfigurationDatabase, SiteRuntime, and Transport. Follow-up **#93/M3** (populate `InstanceAttributeOverride.ElementDataType` on write) was folded into NJ-2 so the central normalizer can read the override element type directly.
**Branch:** `feature/native-typed-json`
## Problem
The multi-value (List) attribute feature (shipped 2026-06-16, branch `feature/multivalue-attribute`) stores List values via `AttributeValueCodec` as a JSON **array of strings** — e.g. an `Int32` list is `["10","20","30"]` and a `Boolean` list is `["True","False"]`. This is internally consistent and round-trips, but it is not "native-typed" JSON: numbers and booleans are quoted, and `DateTime` uses a US-invariant format rather than ISO-8601. We want the canonical form to be native-typed (`[10,20,30]`, `[true,false]`, ISO dates), while existing persisted data is normalized to the new form (no dual-format data left behind).
## Decisions
| Decision | Choice |
|---|---|
| Encode form | Native-typed JSON: numbers/bools unquoted, strings quoted, `DateTime` as ISO-8601 string |
| Decode | **Read both** old (array-of-strings) and new (native) forms — backward compatible |
| Existing data | **Migrate** to native form across MS SQL + site SQLite + on bundle import (Approach B, thorough) |
| MS SQL mechanism | Idempotent C# **startup normalizer** (not T-SQL — type-aware JSON re-emission is fragile in SQL) |
| Site SQLite mechanism | **Active** normalization in the InstanceActor override-load path (it already has the element type) |
| Bundles | Normalize **on import** (already-exported files are external/unreachable) |
**Reality note:** the List feature shipped this session and was not deployed to the docker cluster, so there is almost certainly **zero** old-form List data in any store yet. The migration is a safety net guaranteeing no dual-format data ever lingers, not a fix for existing broken data.
## Architecture
### 1. Codec (`AttributeValueCodec`, Commons) — foundation
- **`Encode`** — only the list branch changes. Instead of mapping each element to an invariant string then serializing, serialize the typed CLR collection directly (`JsonSerializer.Serialize<object>(enumerable, …)`) so `System.Text.Json` emits each element in its native JSON kind. STJ numbers/bools are culture-invariant by spec; `DateTime` serializes as round-trippable ISO-8601. Scalars (the `string` / `IFormattable` branches) are untouched.
- **`Decode`** — read both forms. Deserialize to `JsonElement[]` (instead of `string?[]`); for each element feed `ParseScalar` either `GetString()` (JSON string element) or `GetRawText()` (number/bool element). So `[10,20]` and `["10","20"]` both decode to `List<int>{10,20}`; ISO and old US-invariant `DateTime` strings both parse via the existing `DateTime.Parse(…, RoundtripKind)`. A JSON `null` element still throws `FormatException` ("elements may not be null"), unchanged.
- The read-both Decode is also what makes the migration idempotent: re-encoding an already-native value yields identical bytes.
### 2. MS SQL — idempotent central startup normalizer
A normalization step invoked once after `dbContext.Database.MigrateAsync(...)` in `MigrationHelper.ApplyOrValidateMigrationsAsync` (active central node only). For each List row:
- **`TemplateAttributes`** where `DataType = 'List'`: read `Value` + the row's own `ElementDataType`; compute `Encode(Decode(value, List, elementType))`; if it differs from the stored string, `UPDATE`.
- **`InstanceAttributeOverrides`** for List attributes: these rows may have a null `ElementDataType` (it is currently informational — see follow-up #93/M3), so resolve the element type via the owning instance's template attribute (instance → `TemplateId``TemplateAttribute` by name → `ElementDataType`). Then re-encode as above.
Idempotent (native→native is a no-op `UPDATE`-skip), so the step is safe to leave in permanently and cheap on every subsequent startup (a scan, no writes). Per-row failures (malformed JSON, unresolved element type) are logged and skipped — normalization NEVER aborts startup (mirrors the audit/best-effort principle). The scan is bounded to List rows only.
### 3. Site SQLite — active normalization on override load
Site static-override values (`SiteStorageService`) are keyed by `(instance, canonicalName)` and carry no element type — the element type lives in the instance's flattened config. The natural normalization point is therefore the **InstanceActor override-load path** (`HandleOverridesLoaded`, added in MV-7), which already decodes both forms using the `ResolvedAttribute`'s `ElementDataType`. Extend it so that when a List override's stored string is in old form (i.e. `Encode(decoded)` differs from the stored string), it re-persists the native form via `SiteStorageService.SetStaticOverrideAsync`. This actively normalizes on every instance load (site startup / failover), reuses the existing decode + element type, and is idempotent.
### 4. Bundles — normalize on import
Already-exported `.bundle` files are external artifacts we cannot reach to rewrite; import already reads both forms via the codec. To ensure imported List values land in native form in the DB, the importer re-encodes List attribute `Value`s through the codec when writing (and the MS SQL normalizer is a backstop on next startup). No file rewriting.
## Error handling
- Decode of a genuinely malformed value still throws `FormatException`; the normalizers catch it per-row, log, and skip (no startup abort, no actor crash).
- The codec change is additive on the wire (`gRPC` `string value` field unchanged; `List` is a new type with no external wire consumer relying on the old quoted form).
## Testing
- **Codec:** native-form encode per element type (`[10,20,30]`, `[true,false]`, ISO `DateTime`, strings stay `["a","b"]`); old-form backward-compat decode (`["10","20"]``List<int>`); round-trip for every element type; malformed still throws; culture-invariance preserved.
- **MS SQL normalizer:** old-form row → rewritten to native; native row → untouched (idempotency); malformed row → skipped + logged, other rows still processed; override row element-type resolved via template attribute.
- **Site SQLite / InstanceActor:** an old-form List override on load → re-persisted native (assert `SetStaticOverrideAsync` called with native form); a native override → not re-persisted (idempotent); scalar overrides unaffected.
- **Bundle import:** importing an old-form bundle lands native-form Values in the DB.
## Out of scope / follow-ups
- **Deployed-config snapshot is a fourth, un-normalized List-value store (latent gap, I-1).**
`DeployedConfigSnapshot.ConfigurationJson` + `RevisionHash` freeze the flattened config at
deploy time. The staleness/diff path (`DeploymentService.GetDeploymentComparisonAsync`
`DiffService.AttributesEqual` ordinal compare + `RevisionHashService` SHA over the raw
`Value`) compares that frozen blob against a freshly-flattened (now native-form) config. If a
List attribute was ever *deployed* in old-form, the snapshot stays old-form → a spurious
"Changed" diff + false staleness flag until redeployed. This **cannot fire against current
data** (no List attributes were ever deployed — see the Reality Note), so it is recorded as a
known latent gap, not fixed. If hardening is wanted before List attributes are deployed at
scale: route the deserialized snapshot's List values through `Decode→Encode` in
`GetDeploymentComparisonAsync` before the diff/hash (symmetric with the other normalizers).
- CLI `template attribute` help still illustrates `--value` with a quoted string-list example;
add a native-form numeric example (e.g. `[10,20]`) so users don't hand-author quoted numbers
that get silently re-normalized. Doc-only; the quoted form still decodes.
- Rewriting already-exported bundle files (unreachable).
- This pairs naturally with follow-up **#93/M3** (populate `InstanceAttributeOverride.ElementDataType` on write); if done, the override normalizer could read the column directly instead of joining to the template attribute. Not required here.
+282
View File
@@ -0,0 +1,282 @@
# Native-Typed JSON for List Attributes — 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:** Make `AttributeValueCodec` encode List values as native-typed JSON (`[10,20]`, `[true,false]`, ISO dates) while decoding both old (array-of-strings) and new forms, and normalize already-persisted data to the native form across MS SQL, site SQLite, and bundle import.
**Architecture:** Native `Encode` + read-both `Decode` in the codec is the foundation. An idempotent central MS SQL startup normalizer rewrites old-form rows; the InstanceActor re-persists native form on override-load (site SQLite); the bundle importer re-encodes on import. To give the central normalizer a reliable element type for instance-override rows, the override-write path now stamps `ElementDataType` (folds in follow-up #93/M3).
**Tech Stack:** C#/.NET 10, System.Text.Json, EF Core 10 (MS SQL + SQLite), Akka.NET, Transport bundles.
**Design doc:** `docs/plans/2026-06-16-native-typed-json-design.md` (approved).
**Branch:** `feature/native-typed-json` (off main; design committed `91b1aa1`).
**Conventions for every task:** TDD (failing test → fail → implement → pass → commit). Targeted builds/tests only — `dotnet build src/<Project>/<Project>.csproj`, `dotnet test tests/<TestProject>/<TestProject>.csproj --filter <Name>`. Full-solution build only in the final task. Do NOT create git worktrees. Commit with pathspec form (`git commit -m "…" -- <paths>`) and retry on `.git/index.lock` when implementers run concurrently.
---
### Task NJ-1: Codec — native-typed Encode + read-both Decode
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** NJ-2
**Blocked by:** none (foundation)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs`
- Modify: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs`
This changes the canonical on-disk/on-wire form for List values. It is backward compatible (Decode reads both forms) and additive on the gRPC `string value` field (List is a new type with no external consumer of the old quoted form).
**Step 1 — Update/extend tests (TDD).** Update the existing `Encode_DoubleList_IsInvariant` assertion to the native form, and add the native + backward-compat cases:
```csharp
[Fact]
public void Encode_Int32List_ProducesNativeNumbers() =>
Assert.Equal("[10,20,30]", AttributeValueCodec.Encode(new List<int> { 10, 20, 30 }));
[Fact]
public void Encode_BoolList_ProducesNativeBooleans() =>
Assert.Equal("[true,false]", AttributeValueCodec.Encode(new List<bool> { true, false }));
[Fact]
public void Encode_DoubleList_IsNativeAndInvariant()
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
Assert.Equal("[1.5,2.5]", AttributeValueCodec.Encode(new List<double> { 1.5, 2.5 }));
}
finally { CultureInfo.CurrentCulture = original; }
}
[Fact]
public void Encode_StringList_StaysQuoted() =>
Assert.Equal("[\"a\",\"b\"]", AttributeValueCodec.Encode(new List<string> { "a", "b" }));
[Fact]
public void Encode_DateTimeList_IsIso8601()
{
var json = AttributeValueCodec.Encode(new List<DateTime> { new(2026, 6, 16, 0, 0, 0, DateTimeKind.Utc) });
Assert.Contains("2026-06-16T00:00:00", json); // ISO, not "06/16/2026 00:00:00"
}
[Fact]
public void Decode_NewNativeIntForm_Parses()
{
var back = (IList<int>)AttributeValueCodec.Decode("[10,20]", DataType.List, DataType.Int32)!;
Assert.Equal(new[] { 10, 20 }, back);
}
[Fact]
public void Decode_OldStringIntForm_BackwardCompatible()
{
var back = (IList<int>)AttributeValueCodec.Decode("[\"10\",\"20\"]", DataType.List, DataType.Int32)!;
Assert.Equal(new[] { 10, 20 }, back);
}
[Theory]
[InlineData("[true,false]")] // new native bools
[InlineData("[\"True\",\"False\"]")] // old string bools
public void Decode_BoolForms_BothParse(string json)
{
var back = (IList<bool>)AttributeValueCodec.Decode(json, DataType.List, DataType.Boolean)!;
Assert.Equal(new[] { true, false }, back);
}
```
Keep the existing round-trip tests (Int32/Float/Double/DateTime/Bool) — they assert round-trip, not exact form, so they continue to pass. Keep `Decode_MalformedJson_Throws`.
**Step 2 — Run, expect FAIL** on the new native-form encode assertions.
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter AttributeValueCodecTests`
**Step 3 — Implement.** In `Encode`, replace ONLY the `IEnumerable` branch:
```csharp
case IEnumerable e:
// Native-typed JSON: serialize the runtime collection so STJ emits numbers/bools
// unquoted, strings quoted, DateTime as ISO-8601. Boxed as object so STJ uses the
// runtime element type. STJ numbers/dates are culture-invariant by spec.
return JsonSerializer.Serialize<object>(e, JsonOpts);
```
(The `null`, `string`, and `IFormattable` scalar branches are unchanged.)
In `Decode`, change the list deserialization to read `JsonElement[]` and derive a string per element:
```csharp
JsonElement[] raw;
try { raw = JsonSerializer.Deserialize<JsonElement[]>(value) ?? []; }
catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); }
var clrType = ElementClrType(elementType.Value);
var listType = typeof(List<>).MakeGenericType(clrType);
var result = (IList)Activator.CreateInstance(listType)!;
foreach (var el in raw)
result.Add(ParseScalar(JsonElementToString(el), elementType.Value));
return result;
```
Add the helper (handles both new native elements and old quoted-string elements):
```csharp
private static string? JsonElementToString(JsonElement el) => el.ValueKind switch
{
JsonValueKind.String => el.GetString(), // old form, or string-typed lists
JsonValueKind.Null => null, // ParseScalar throws "may not be null"
_ => el.GetRawText() // number/bool → "10" / "1.5" / "true"
};
```
`ParseScalar` is unchanged: it parses the string per element type (invariant), so `"10"` (from either form), `"true"`, and ISO/US-invariant dates all parse. Add `using System.Text.Json;` if not already present (it is).
**Step 4 — Run, expect PASS** (all AttributeValueCodec tests).
**Step 5 — Commit.**
```bash
git commit -m "feat(commons): native-typed JSON for List values; Decode reads both forms" -- \
src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs \
tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs
```
**Acceptance:** numbers/bools encode unquoted, strings stay quoted, DateTime is ISO; both old and new forms decode; scalars unchanged; malformed still throws.
---
### Task NJ-2: Stamp ElementDataType on instance-override writes
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** NJ-1
**Blocked by:** none
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs:146-198` (`SetAttributeOverrideAsync`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/InstanceServiceTests.cs` (mirror existing style)
`SetAttributeOverrideAsync` already loads the base `templateAttr` (line 159). Set `ElementDataType` from it on both the update and create branches so the persisted `InstanceAttributeOverride` row carries the element type — this is what the NJ-3 central normalizer reads for override rows (folds in follow-up #93/M3).
**Step 1 — Failing test:** setting an override on a `List`/`String` template attribute persists an `InstanceAttributeOverride` whose `ElementDataType == DataType.String`; setting an override on a scalar attribute persists `ElementDataType == null`.
**Step 2 — Run, expect FAIL.**
**Step 3 — Implement:** in the update branch, `existingOverride.ElementDataType = templateAttr.ElementDataType;` before the repo update; in the create branch, set `ElementDataType = templateAttr.ElementDataType` in the `new InstanceAttributeOverride(...)` initializer.
**Step 4 — Run, expect PASS.**
**Step 5 — Commit:** `feat(template): stamp ElementDataType on instance attribute overrides`.
---
### Task NJ-3: Central MS SQL idempotent startup normalizer
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** NJ-4, NJ-5 (after NJ-1)
**Blocked by:** NJ-1, NJ-2
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ListValueNormalizer.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/MigrationHelper.cs` (call the normalizer after the migrate/validate step)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/...` (use the existing test DbContext/SQLite-in-memory or test-double pattern the project uses — investigate the existing ConfigurationDatabase tests first)
**Behavior:** A static `ListValueNormalizer.NormalizeAsync(ScadaBridgeDbContext db, ILogger? logger, CancellationToken ct)`:
- `TemplateAttributes` where `DataType == DataType.List`: for each, compute `var native = AttributeValueCodec.Encode(AttributeValueCodec.Decode(a.Value, DataType.List, a.ElementDataType));` (parse the row's own `ElementDataType` — it is a `DataType?`). If `native != a.Value`, set `a.Value = native`.
- `InstanceAttributeOverrides` whose `ElementDataType != null` (i.e. a List override stamped by NJ-2): same re-encode against `OverrideValue`. Rows with null `ElementDataType` are skipped with a debug log (legacy/scalar rows — none exist in practice since the feature has no deployed data).
- Wrap each row in try/catch(`FormatException`): on a malformed value, log a warning and SKIP that row — continue with the rest. The normalizer MUST NOT throw out of `NormalizeAsync` (never abort startup).
- `SaveChangesAsync` once at the end. Log a summary (`Normalized {n} list attribute value(s) to native JSON`).
- Idempotent: native→native yields identical bytes, so a second run makes no changes.
**Wire-in (MigrationHelper):** after the `if (isDevelopment) { Migrate } else { validate }` block in `ApplyOrValidateMigrationsAsync`, call `await ListValueNormalizer.NormalizeAsync(dbContext, logger, cancellationToken);`. (Runs on every central startup; idempotent and safe even if both central nodes run it — concurrent UPDATEs set identical values.)
**Steps (TDD):** failing tests (a TemplateAttributes row in old form `["10","20"]` with ElementDataType Int32 → rewritten to `[10,20]`; a native row → unchanged/no-op; a String-list `["a","b"]` → unchanged; a malformed row → skipped, sibling rows still normalized; an override row with ElementDataType set → normalized, with null → skipped) → run targeted → FAIL → implement `ListValueNormalizer` + wire-in → run targeted → PASS → build ConfigurationDatabase → commit `feat(db): idempotent startup normalizer rewriting List values to native JSON`.
---
### Task NJ-4: Site SQLite active normalization on override load
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** NJ-3, NJ-5 (after NJ-1)
**Blocked by:** NJ-1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:932-960` (`HandleOverridesLoaded`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs`
In `HandleOverridesLoaded`, the List branch already decodes the stored override via `DecodeAttributeValue(resolved, kvp.Value)`. Extend it: when the decode succeeds and the re-encoded native form differs from the stored string (i.e. the stored value is old-form), re-persist the native form:
```csharp
var decoded = DecodeAttributeValue(resolved, kvp.Value);
_attributes[kvp.Key] = decoded;
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
{
_attributeQualities[kvp.Key] = "Bad";
}
else if (decoded is not null)
{
var native = AttributeValueCodec.Encode(decoded);
if (native != kvp.Value) // stored value was old-form → normalize on disk
{
var key = kvp.Key;
_storage.SetStaticOverrideAsync(_instanceUniqueName, key, native!)
.ContinueWith(t => _logger.LogWarning(t.Exception?.GetBaseException(),
"Failed to normalize static override {Instance}.{Attr} to native JSON", _instanceUniqueName, key),
TaskContinuationOptions.OnlyOnFaulted);
}
}
```
Idempotent (native→native ⇒ `native == kvp.Value` ⇒ no write). Scalars untouched. Never throws into the actor (decode already FormatException-safe; persist is fire-and-forget).
**Steps (TDD):** failing tests (an old-form List static override on load → `SetStaticOverrideAsync` called with the native form; a native-form override → NOT re-persisted; a scalar override → unaffected; malformed → Bad quality + no crash, as today) → run targeted `--filter "FullyQualifiedName~InstanceActor"` → FAIL → implement → PASS → build SiteRuntime → commit `feat(siteruntime): normalize old-form List static overrides to native JSON on load`.
---
### Task NJ-5: Normalize List values on bundle import
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** NJ-3, NJ-4 (after NJ-1)
**Blocked by:** NJ-1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs` (the 3 `TemplateAttribute` construction sites from MV-12: BuildTemplate + the two in SyncTemplateAttributesAsync) — and/or `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs` `FromBundleContent` if that is the single choke point for DTO→entity.
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/...`
**Behavior:** when importing a `TemplateAttributeDto` whose `DataType == DataType.List`, write the **native** Value: `var v = NormalizeListValue(dto.Value, dto.ElementDataType);` where `NormalizeListValue` does `AttributeValueCodec.Encode(AttributeValueCodec.Decode(value, DataType.List, elementType))` inside try/catch(FormatException) → on failure return the original value unchanged + log (don't fail the import; the MS SQL normalizer is a backstop). Prefer a single private helper used at all construction sites (DRY). Non-List attributes write `dto.Value` unchanged.
**Steps (TDD):** failing test (import a bundle/DTO with an old-form List attribute `["10","20"]` + ElementDataType Int32 → the persisted TemplateAttribute.Value is `[10,20]`; a String list stays quoted; a malformed value imports unchanged) → run targeted → FAIL → implement the helper + apply at construction sites → PASS → build Transport → commit `feat(transport): normalize List attribute values to native JSON on import`.
---
### Task NJ-6: Integration verification + docs
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Blocked by:** NJ-1 … NJ-5
**Files:**
- Modify: `docs/plans/2026-06-16-native-typed-json-design.md` (mark Status complete)
- Modify: `docs/requirements/Component-Commons.md` (update the `DataType`/codec note: List values are native-typed JSON; codec reads both forms)
- Modify: `docs/plans/2026-06-16-multivalue-attribute.md.tasks.json` follow-up note OR the task list — note that #93/M3 (override ElementDataType) was folded in here.
**Steps:**
1. Full-solution build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0/0.
2. Targeted test sweep across touched projects: Commons codec, TemplateEngine (InstanceService), ConfigurationDatabase (normalizer), SiteRuntime (InstanceActor), Transport (serializer/importer).
3. Update docs; mark design Status complete.
4. Commit: `docs: mark native-typed JSON feature complete; update Component-Commons codec note`.
**Acceptance:** full build green; all touched-project targeted tests pass; docs synced.
---
## Parallelization summary
- **Wave 1:** NJ-1 (codec) ∥ NJ-2 (override stamp) — disjoint (Commons vs TemplateEngine).
- **Wave 2 (after NJ-1, +NJ-2 for NJ-3):** NJ-3 (normalizer), NJ-4 (site), NJ-5 (import) in parallel — disjoint (ConfigurationDatabase vs SiteRuntime vs Transport).
- **Wave 3:** NJ-6 (verify + docs).
## Risk notes
- **NJ-1** changes the canonical storage/wire form — high-risk, but backward-compatible (read-both) and additive on the proto field.
- **NJ-3** writes to the config DB at startup — must be idempotent and never abort startup (per-row skip+log).
- **NJ-4** touches the actor model — the normalization write is fire-and-forget and never throws into the actor.
- There is no deployed List data yet, so the normalizers are a safety net; correctness of NJ-1 (the format) is what matters most in practice.
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-06-16-native-typed-json.md",
"designDoc": "docs/plans/2026-06-16-native-typed-json-design.md",
"branch": "feature/native-typed-json",
"status": "complete",
"tasks": [
{"id": 96, "ref": "NJ-1", "subject": "Codec — native-typed Encode + read-both Decode", "class": "high-risk", "status": "completed", "commits": ["180d554", "bf80ca1"]},
{"id": 97, "ref": "NJ-2", "subject": "Stamp ElementDataType on instance-override writes (folds in #93/M3)", "class": "small", "status": "completed", "commits": ["abe8832"]},
{"id": 98, "ref": "NJ-3", "subject": "Central MS SQL idempotent startup normalizer", "class": "high-risk", "status": "completed", "commits": ["f4b101b", "feeae13"]},
{"id": 99, "ref": "NJ-4", "subject": "Site SQLite active normalization on override load", "class": "high-risk", "status": "completed", "commits": ["5841cec", "feeae13"]},
{"id": 100, "ref": "NJ-5", "subject": "Normalize List values on bundle import", "class": "standard", "status": "completed", "commits": ["e3d804a", "feeae13"]},
{"id": 101, "ref": "NJ-6", "subject": "Integration verification + docs", "class": "standard", "status": "completed"}
],
"lastUpdated": "2026-06-16",
"note": "feature/native-typed-json was reset off these commits during concurrent other-window git work and restored to f4b101b (the NJ-1..NJ-5 tip) via reflog; NJ-3/4/5 review fixes committed as feeae13 + NJ-6 docs on top."
}
+1 -1
View File
@@ -26,7 +26,7 @@ Referenced by all component libraries and the Host.
Commons must define shared primitive and utility types used across multiple components, including but not limited to:
- **`DataType` enum**: Enumerates the data types supported by the system: Boolean, Int32, Float, Double, String, DateTime, Binary, and **List** (a homogeneous multi-value attribute). A `List` attribute carries a companion `ElementDataType` (one of the scalar types — String, Int32, Float, Double, Boolean, DateTime; not Binary, not nested List) on `TemplateAttribute` / `InstanceAttributeOverride` and the flattened `ResolvedAttribute`. List values are stored and transmitted as a canonical JSON array via the `AttributeValueCodec` helper (scalars keep their historical invariant-culture string form); the `ElementDataType` is fixed by the defining level and cannot be changed on a derived template or instance override.
- **`DataType` enum**: Enumerates the data types supported by the system: Boolean, Int32, Float, Double, String, DateTime, Binary, and **List** (a homogeneous multi-value attribute). A `List` attribute carries a companion `ElementDataType` (one of the scalar types — String, Int32, Float, Double, Boolean, DateTime; not Binary, not nested List) on `TemplateAttribute` / `InstanceAttributeOverride` and the flattened `ResolvedAttribute`. List values are stored and transmitted as a canonical **native-typed** JSON array via the `AttributeValueCodec` helper — numbers and booleans unquoted, strings quoted, `DateTime` as ISO-8601 (e.g. `[10,20]`, `[true,false]`, `["2026-06-16T00:00:00Z"]`); scalars keep their historical invariant-culture string form. `AttributeValueCodec.Decode` is backward compatible: it reads both the native form and the earlier array-of-strings form (`["10","20"]`). Already-persisted old-form values are normalized to native on the fly — an idempotent central startup normalizer (`ListValueNormalizer`, run after migrations) rewrites MS SQL rows, the `InstanceActor` re-persists site SQLite overrides on load, and bundle import re-encodes List values. The `ElementDataType` is fixed by the defining level and cannot be changed on a derived template or instance override.
- **`RetryPolicy`**: A record or immutable class describing retry behavior (max retries, fixed delay between retries).
- **`Result<T>`**: A discriminated result type that represents either a success value or an error, enabling consistent error handling across component boundaries without exceptions.
- **`InstanceState` enum**: Enabled, Disabled.
@@ -25,11 +25,10 @@ public static class AttributeValueCodec
case string s: return s; // already canonical
case IFormattable f: return f.ToString(null, CultureInfo.InvariantCulture);
case IEnumerable e:
var items = e.Cast<object?>()
.Select(x => x is IFormattable xf
? xf.ToString(null, CultureInfo.InvariantCulture)
: x?.ToString());
return JsonSerializer.Serialize(items, JsonOpts);
// Native-typed JSON: serialize the runtime collection so System.Text.Json emits
// numbers/bools unquoted, strings quoted, and DateTime as ISO-8601. Boxed as object
// so STJ uses the runtime element type. STJ numbers/dates are culture-invariant.
return JsonSerializer.Serialize<object>(e, JsonOpts);
default: return value.ToString();
}
}
@@ -46,18 +45,25 @@ public static class AttributeValueCodec
if (elementType is null)
throw new FormatException("List attribute requires an element type.");
string?[] raw;
try { raw = JsonSerializer.Deserialize<string?[]>(value) ?? []; }
JsonElement[] raw;
try { raw = JsonSerializer.Deserialize<JsonElement[]>(value) ?? []; }
catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); }
var clrType = ElementClrType(elementType.Value);
var listType = typeof(List<>).MakeGenericType(clrType);
var result = (IList)Activator.CreateInstance(listType)!;
foreach (var item in raw)
result.Add(ParseScalar(item, elementType.Value));
foreach (var el in raw)
result.Add(ParseScalar(JsonElementToString(el), elementType.Value));
return result;
}
private static string? JsonElementToString(JsonElement el) => el.ValueKind switch
{
JsonValueKind.String => el.GetString(), // old form, or string-typed lists
JsonValueKind.Null => null, // ParseScalar throws "may not be null"
_ => el.GetRawText() // number/bool → "10" / "1.5" / "true"
};
private static Type ElementClrType(DataType t) => t switch
{
DataType.String => typeof(string),
@@ -0,0 +1,118 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
/// <summary>
/// Idempotent central startup normalizer that rewrites already-persisted List attribute
/// values from the old array-of-strings JSON form (<c>["10","20"]</c>) to the new
/// native-typed form (<c>[10,20]</c>).
///
/// <para>
/// This is a safety net: at the time of writing there is no deployed List attribute data,
/// so in practice it finds nothing to rewrite. It runs on every central startup after
/// migrations apply and MUST never abort startup for data reasons — every row's work is
/// wrapped in a per-row try/catch that logs and skips on malformed data. A second run
/// finds nothing to change (native → native re-encode is byte-identical).
/// </para>
/// </summary>
public static class ListValueNormalizer
{
/// <summary>
/// Rewrites old-form List attribute values to the native-typed JSON form across
/// <see cref="ScadaBridgeDbContext.TemplateAttributes"/> and
/// <see cref="ScadaBridgeDbContext.InstanceAttributeOverrides"/>. Idempotent and
/// best-effort: malformed rows are logged and skipped, never rethrown.
/// </summary>
/// <param name="db">The configuration database context.</param>
/// <param name="logger">Optional logger for diagnostics.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public static async Task NormalizeAsync(
ScadaBridgeDbContext db,
ILogger? logger = null,
CancellationToken ct = default)
{
var rewritten = 0;
// TemplateAttributes: List rows carry the element type on the row itself.
var templateRows = await db.TemplateAttributes
.Where(a => a.DataType == DataType.List)
.ToListAsync(ct);
foreach (var a in templateRows)
{
try
{
var native = AttributeValueCodec.Encode(
AttributeValueCodec.Decode(a.Value, DataType.List, a.ElementDataType));
if (native != a.Value)
{
a.Value = native;
rewritten++;
}
}
catch (Exception ex)
{
// Never abort startup for a single bad row. FormatException from Decode is the
// expected case; the broad catch also covers an unexpected serialize failure
// (e.g. a JsonException on a non-finite value) so one poison row can't crash boot.
logger?.LogWarning(ex,
"List value normalizer: skipping unprocessable list value for TemplateAttribute {Id}.",
a.Id);
}
}
// InstanceAttributeOverrides: only rows that carry an element type are List rows.
// Rows with a null ElementDataType are scalar/legacy rows (no deployed List data
// exists, so none in practice) and are skipped.
var overrideRows = await db.InstanceAttributeOverrides
.Where(o => o.ElementDataType != null)
.ToListAsync(ct);
foreach (var o in overrideRows)
{
try
{
var native = AttributeValueCodec.Encode(
AttributeValueCodec.Decode(o.OverrideValue, DataType.List, o.ElementDataType));
if (native != o.OverrideValue)
{
o.OverrideValue = native;
rewritten++;
}
}
catch (Exception ex)
{
logger?.LogWarning(ex,
"List value normalizer: skipping unprocessable list value for InstanceAttributeOverride {Id}.",
o.Id);
}
}
try
{
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
// A catastrophic DB failure on SaveChanges may propagate, but log it first so
// startup diagnostics are not silent. Per-row data problems are already handled
// above and never reach here.
logger?.LogError(ex, "List value normalizer: SaveChanges failed.");
throw;
}
if (rewritten > 0)
{
logger?.LogInformation(
"List value normalizer: rewrote {n} attribute value(s) to native JSON.", rewritten);
}
else
{
logger?.LogDebug("List value normalizer: no attribute values required rewriting.");
}
}
}
@@ -47,6 +47,12 @@ public static class MigrationHelper
"Apply migrations using 'dotnet ef database update' or the generated SQL scripts before starting in production mode.");
}
}
// Safety-net normalizer: rewrite any already-persisted List attribute values from the
// old array-of-strings JSON form to the new native-typed form. Idempotent and never
// aborts startup for data reasons (per-row skip + log). Safe even if both central
// nodes run it concurrently on startup.
await ListValueNormalizer.NormalizeAsync(dbContext, logger, cancellationToken);
}
private static async Task WaitForDatabaseReadyAsync(
@@ -948,10 +948,33 @@ public class InstanceActor : ReceiveActor
if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved)
&& IsListAttribute(resolved))
{
// NJ-4: decode the stored List override (both old array-of-strings
// and native-typed forms decode) and re-persist the native form if
// the stored value is still in the OLD form. Re-encoding the decoded
// list and comparing to the stored string detects old-form values
// (native → native is byte-identical, so a native value is a no-op).
// The re-persist is fire-and-forget and never throws into the actor.
var decoded = DecodeAttributeValue(resolved, kvp.Value);
_attributes[kvp.Key] = decoded;
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
{
_attributeQualities[kvp.Key] = "Bad";
}
else if (decoded is not null)
{
var native = AttributeValueCodec.Encode(decoded);
if (native != kvp.Value) // stored value was old-form → normalize on disk
{
var key = kvp.Key;
var logger = _logger;
var instanceName = _instanceUniqueName;
_storage.SetStaticOverrideAsync(instanceName, key, native!)
.ContinueWith(t => logger.LogWarning(t.Exception?.GetBaseException(),
"Failed to normalize static override {Instance}.{Attr} to native JSON",
instanceName, key),
TaskContinuationOptions.OnlyOnFaulted);
}
}
}
else
{
@@ -172,6 +172,7 @@ public class InstanceService
if (existingOverride != null)
{
existingOverride.OverrideValue = overrideValue;
existingOverride.ElementDataType = templateAttr.ElementDataType;
await _repository.UpdateInstanceAttributeOverrideAsync(existingOverride, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
@@ -185,7 +186,8 @@ public class InstanceService
var newOverride = new InstanceAttributeOverride(attributeName)
{
InstanceId = instanceId,
OverrideValue = overrideValue
OverrideValue = overrideValue,
ElementDataType = templateAttr.ElementDataType
};
await _repository.AddInstanceAttributeOverrideAsync(newOverride, cancellationToken);
@@ -1,6 +1,7 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
@@ -70,6 +71,7 @@ public sealed class BundleImporter : IBundleImporter
private readonly IOptions<TransportOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly SemanticValidator _semanticValidator;
private readonly ILogger<BundleImporter>? _logger;
/// <summary>
/// Initializes a new <see cref="BundleImporter"/> with all required dependencies.
@@ -106,7 +108,8 @@ public sealed class BundleImporter : IBundleImporter
IAuditService auditService,
IAuditCorrelationContext correlationContext,
ScadaBridgeDbContext dbContext,
SemanticValidator semanticValidator)
SemanticValidator semanticValidator,
ILogger<BundleImporter>? logger = null)
{
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
_manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator));
@@ -124,6 +127,7 @@ public sealed class BundleImporter : IBundleImporter
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
_logger = logger;
}
/// <inheritdoc />
@@ -1040,7 +1044,7 @@ public sealed class BundleImporter : IBundleImporter
{
t.Attributes.Add(new TemplateAttribute(a.Name)
{
Value = a.Value,
Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType),
DataType = a.DataType,
IsLocked = a.IsLocked,
Description = a.Description,
@@ -1115,11 +1119,17 @@ public sealed class BundleImporter : IBundleImporter
// Adds + Updates.
foreach (var attrDto in dto.Attributes)
{
// Normalise List values to the native-typed JSON form on import so the
// comparison (and the persisted value) match what the target already
// stores natively — otherwise an idempotent re-import of an old-form
// bundle would spuriously report a Value change.
var normalizedValue = ImportValueNormalizer.NormalizeListValue(
attrDto.Value, attrDto.DataType, attrDto.ElementDataType, _logger, attrDto.Name);
if (existingByName.TryGetValue(attrDto.Name, out var current))
{
// Update only if any field actually changed.
bool changed =
!string.Equals(current.Value, attrDto.Value, StringComparison.Ordinal) ||
!string.Equals(current.Value, normalizedValue, StringComparison.Ordinal) ||
current.DataType != attrDto.DataType ||
current.IsLocked != attrDto.IsLocked ||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
@@ -1127,7 +1137,7 @@ public sealed class BundleImporter : IBundleImporter
current.ElementDataType != attrDto.ElementDataType;
if (!changed) continue;
current.Value = attrDto.Value;
current.Value = normalizedValue;
current.DataType = attrDto.DataType;
current.IsLocked = attrDto.IsLocked;
current.Description = attrDto.Description;
@@ -1157,7 +1167,7 @@ public sealed class BundleImporter : IBundleImporter
{
var newAttr = new TemplateAttribute(attrDto.Name)
{
Value = attrDto.Value,
Value = normalizedValue,
DataType = attrDto.DataType,
IsLocked = attrDto.IsLocked,
Description = attrDto.Description,
@@ -199,7 +199,7 @@ public sealed class EntitySerializer
t.Attributes.Add(new TemplateAttribute(a.Name)
{
TemplateId = t.Id,
Value = a.Value,
Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType),
DataType = a.DataType,
IsLocked = a.IsLocked,
Description = a.Description,
@@ -0,0 +1,63 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
/// <summary>
/// Import-time normalization of attribute values to the native-typed JSON form.
/// <para>
/// Bundles exported before the native-typed-JSON change carry List attribute
/// values in the old quoted-element form (e.g. <c>["10","20"]</c> for an
/// <see cref="DataType.Int32"/> list). Already-exported bundle files can't be
/// rewritten, so List values are normalised on import: every DTO→entity write
/// site routes the value through <see cref="NormalizeListValue"/> so imported
/// data lands native (<c>[10,20]</c>). The central DB normalizer remains the
/// backstop for anything that slips through.
/// </para>
/// <para>
/// Non-List attributes and null/empty values pass through unchanged. A value
/// that fails to decode (malformed JSON / un-parseable element) is left exactly
/// as-is so the import still succeeds — the DB normalizer is the backstop.
/// </para>
/// </summary>
internal static class ImportValueNormalizer
{
/// <summary>
/// Returns the native-typed JSON form of a List attribute value, or the
/// value unchanged for non-List / null / empty / malformed inputs.
/// </summary>
/// <param name="value">The attribute value as carried by the bundle DTO.</param>
/// <param name="dataType">The attribute's declared data type.</param>
/// <param name="elementType">The List element type (null for scalars).</param>
/// <param name="logger">Optional logger; a warning is emitted when a malformed value is left as-is.</param>
/// <param name="attributeName">Optional attribute name for the diagnostic message.</param>
public static string? NormalizeListValue(
string? value,
DataType dataType,
DataType? elementType,
ILogger? logger = null,
string? attributeName = null)
{
if (dataType != DataType.List || string.IsNullOrEmpty(value))
{
return value;
}
try
{
return AttributeValueCodec.Encode(
AttributeValueCodec.Decode(value, DataType.List, elementType));
}
catch (FormatException ex)
{
// Leave malformed values exactly as imported; the DB normalizer is
// the backstop. Never abort the import for a single bad value.
logger?.LogWarning(ex,
"Bundle import: could not normalize List value for attribute {Attribute}; " +
"importing verbatim (the central DB normalizer is the backstop).",
attributeName ?? "(unknown)");
return value;
}
}
}
@@ -64,7 +64,7 @@ public class AttributeValueCodecTests
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
Assert.Equal("[\"1.5\",\"2.5\"]",
Assert.Equal("[1.5,2.5]",
AttributeValueCodec.Encode(new List<double> { 1.5, 2.5 }));
}
finally
@@ -73,6 +73,71 @@ public class AttributeValueCodecTests
}
}
[Fact]
public void Encode_Int32List_ProducesNativeNumbers() =>
Assert.Equal("[10,20,30]",
AttributeValueCodec.Encode(new List<int> { 10, 20, 30 }));
[Fact]
public void Encode_BoolList_ProducesNativeBooleans() =>
Assert.Equal("[true,false]",
AttributeValueCodec.Encode(new List<bool> { true, false }));
[Fact]
public void Encode_StringList_StaysQuoted() =>
Assert.Equal("[\"a\",\"b\"]",
AttributeValueCodec.Encode(new List<string> { "a", "b" }));
[Fact]
public void Encode_DateTimeList_IsIso8601()
{
var json = AttributeValueCodec.Encode(
new List<DateTime> { new(2026, 6, 16, 0, 0, 0, DateTimeKind.Utc) });
Assert.StartsWith("[\"", json); // DateTime list must be an array of quoted strings
Assert.Contains("2026-06-16T00:00:00", json);
Assert.DoesNotContain("06/16/2026", json);
}
[Fact]
public void Decode_OldStringFloatForm_BackwardCompatible()
{
var back = (IList<float>)AttributeValueCodec.Decode("[\"1.5\",\"2.25\"]", DataType.List, DataType.Float)!;
Assert.Equal(new[] { 1.5f, 2.25f }, back);
}
[Fact]
public void Decode_OldStringDateTimeForm_BackwardCompatible()
{
// The pre-native codec encoded DateTime via IFormattable.ToString(null, InvariantCulture):
// "06/16/2026 12:30:45" (US-invariant, no 'T'/'Z'). New Decode must still parse it.
var back = (IList<DateTime>)AttributeValueCodec.Decode(
"[\"06/16/2026 12:30:45\"]", DataType.List, DataType.DateTime)!;
Assert.Equal(new DateTime(2026, 6, 16, 12, 30, 45, DateTimeKind.Unspecified), back[0]);
}
[Fact]
public void Decode_NewNativeIntForm_Parses()
{
var back = (IList<int>)AttributeValueCodec.Decode("[10,20]", DataType.List, DataType.Int32)!;
Assert.Equal(new[] { 10, 20 }, back);
}
[Fact]
public void Decode_OldStringIntForm_BackwardCompatible()
{
var back = (IList<int>)AttributeValueCodec.Decode("[\"10\",\"20\"]", DataType.List, DataType.Int32)!;
Assert.Equal(new[] { 10, 20 }, back);
}
[Theory]
[InlineData("[true,false]")]
[InlineData("[\"True\",\"False\"]")]
public void Decode_BoolForms_BothParse(string json)
{
var back = (IList<bool>)AttributeValueCodec.Decode(json, DataType.List, DataType.Boolean)!;
Assert.Equal(new[] { true, false }, back);
}
[Fact]
public void RoundTrip_Int32List()
{
@@ -0,0 +1,231 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Tests for <see cref="ListValueNormalizer"/> — the idempotent startup normalizer that
/// rewrites already-persisted List attribute values from the old array-of-strings JSON
/// form (<c>["10","20"]</c>) to the new native-typed form (<c>[10,20]</c>).
/// </summary>
public class ListValueNormalizerTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
public ListValueNormalizerTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
// Seeds a Template parent (satisfies the TemplateAttribute -> Template FK) and returns its Id.
private async Task<int> SeedTemplateAsync()
{
var template = new Template("T1");
_context.Templates.Add(template);
await _context.SaveChangesAsync();
return template.Id;
}
// Seeds Site + Template + Instance (satisfies the InstanceAttributeOverride -> Instance FK)
// and returns the Instance Id.
private async Task<int> SeedInstanceAsync()
{
var site = new Site("Site1", "S-001");
var template = new Template("T1");
_context.Sites.Add(site);
_context.Templates.Add(template);
await _context.SaveChangesAsync();
var instance = new Instance("Inst1") { SiteId = site.Id, TemplateId = template.Id };
_context.Instances.Add(instance);
await _context.SaveChangesAsync();
return instance.Id;
}
[Fact]
public async Task TemplateAttribute_OldStringForm_RewrittenToNative()
{
var templateId = await SeedTemplateAsync();
_context.TemplateAttributes.Add(new TemplateAttribute("intList")
{
TemplateId = templateId,
DataType = DataType.List,
ElementDataType = DataType.Int32,
Value = "[\"10\",\"20\"]",
});
await _context.SaveChangesAsync();
await ListValueNormalizer.NormalizeAsync(_context);
var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
Assert.Equal("[10,20]", reloaded.Value);
}
[Fact]
public async Task TemplateAttribute_AlreadyNative_IsNotRewritten()
{
var templateId = await SeedTemplateAsync();
_context.TemplateAttributes.Add(new TemplateAttribute("intList")
{
TemplateId = templateId,
DataType = DataType.List,
ElementDataType = DataType.Int32,
Value = "[10,20]",
});
await _context.SaveChangesAsync();
await ListValueNormalizer.NormalizeAsync(_context);
// No tracked entity should be marked Modified — idempotent no-op.
var tracked = _context.ChangeTracker.Entries<TemplateAttribute>()
.Where(e => e.State == EntityState.Modified)
.ToList();
Assert.Empty(tracked);
var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
Assert.Equal("[10,20]", reloaded.Value);
}
[Fact]
public async Task TemplateAttribute_StringList_IsUnchanged()
{
var templateId = await SeedTemplateAsync();
_context.TemplateAttributes.Add(new TemplateAttribute("stringList")
{
TemplateId = templateId,
DataType = DataType.List,
ElementDataType = DataType.String,
Value = "[\"a\",\"b\"]",
});
await _context.SaveChangesAsync();
await ListValueNormalizer.NormalizeAsync(_context);
var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
Assert.Equal("[\"a\",\"b\"]", reloaded.Value);
}
[Fact]
public async Task TemplateAttribute_Malformed_IsSkipped_AndSiblingStillNormalized()
{
var templateId = await SeedTemplateAsync();
_context.TemplateAttributes.Add(new TemplateAttribute("badList")
{
TemplateId = templateId,
DataType = DataType.List,
ElementDataType = DataType.Int32,
Value = "[\"a\"", // malformed JSON
});
_context.TemplateAttributes.Add(new TemplateAttribute("goodList")
{
TemplateId = templateId,
DataType = DataType.List,
ElementDataType = DataType.Int32,
Value = "[\"5\"]", // old form, valid
});
await _context.SaveChangesAsync();
// Must NOT throw despite the malformed row.
await ListValueNormalizer.NormalizeAsync(_context);
var bad = await _context.TemplateAttributes.AsNoTracking()
.SingleAsync(a => a.Name == "badList");
var good = await _context.TemplateAttributes.AsNoTracking()
.SingleAsync(a => a.Name == "goodList");
Assert.Equal("[\"a\"", bad.Value); // skipped, untouched
Assert.Equal("[5]", good.Value); // normalized
}
[Fact]
public async Task TemplateAttribute_NonListRow_IsUnchanged()
{
var templateId = await SeedTemplateAsync();
_context.TemplateAttributes.Add(new TemplateAttribute("scalar")
{
TemplateId = templateId,
DataType = DataType.Int32,
ElementDataType = null,
Value = "42",
});
await _context.SaveChangesAsync();
await ListValueNormalizer.NormalizeAsync(_context);
var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
Assert.Equal("42", reloaded.Value);
}
[Fact]
public async Task InstanceAttributeOverride_OldStringForm_RewrittenToNative()
{
var instanceId = await SeedInstanceAsync();
_context.InstanceAttributeOverrides.Add(new InstanceAttributeOverride("intList")
{
InstanceId = instanceId,
ElementDataType = DataType.Int32,
OverrideValue = "[\"5\"]",
});
await _context.SaveChangesAsync();
await ListValueNormalizer.NormalizeAsync(_context);
var reloaded = await _context.InstanceAttributeOverrides.AsNoTracking().SingleAsync();
Assert.Equal("[5]", reloaded.OverrideValue);
}
[Fact]
public async Task InstanceAttributeOverride_NullElementType_IsUntouched()
{
var instanceId = await SeedInstanceAsync();
_context.InstanceAttributeOverrides.Add(new InstanceAttributeOverride("scalar")
{
InstanceId = instanceId,
ElementDataType = null,
OverrideValue = "42",
});
await _context.SaveChangesAsync();
await ListValueNormalizer.NormalizeAsync(_context);
var reloaded = await _context.InstanceAttributeOverrides.AsNoTracking().SingleAsync();
Assert.Equal("42", reloaded.OverrideValue);
}
[Fact]
public async Task NormalizeAsync_IsIdempotent_SecondRunChangesNothing()
{
var templateId = await SeedTemplateAsync();
_context.TemplateAttributes.Add(new TemplateAttribute("intList")
{
TemplateId = templateId,
DataType = DataType.List,
ElementDataType = DataType.Int32,
Value = "[\"10\",\"20\"]",
});
await _context.SaveChangesAsync();
await ListValueNormalizer.NormalizeAsync(_context);
var afterFirst = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
Assert.Equal("[10,20]", afterFirst.Value);
await ListValueNormalizer.NormalizeAsync(_context);
var afterSecond = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
Assert.Equal("[10,20]", afterSecond.Value);
var tracked = _context.ChangeTracker.Entries<TemplateAttribute>()
.Where(e => e.State == EntityState.Modified)
.ToList();
Assert.Empty(tracked);
}
}
@@ -1016,4 +1016,159 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Single(overrides);
Assert.Equal("[]", overrides["Labels"]);
}
// ── NJ-4: old-form List static override normalization on load ────────────
/// <summary>
/// NJ-4: an OLD array-of-strings static override (<c>["10","20"]</c>) for an
/// Int32 List attribute must be re-persisted in the native form (<c>[10,20]</c>)
/// when the actor loads it at startup. The in-memory read still returns the
/// typed list {10,20}; the on-disk value is normalized to native JSON.
/// </summary>
[Fact]
public async Task InstanceActor_OldFormListOverride_NormalizedToNativeOnLoad()
{
await _storage.SetStaticOverrideAsync("Pump-OldForm", "Counts", "[\"10\",\"20\"]");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-OldForm",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Counts", Value = "[1,2]",
DataType = "List", ElementDataType = "Int32"
}
]
};
var actor = CreateInstanceActor("Pump-OldForm", config);
// Wait for the async override load (PipeTo) + fire-and-forget normalization.
await Task.Delay(1000);
// In-memory read returns the typed list, decoded from the old form.
actor.Tell(new GetAttributeRequest("corr-of", "Pump-OldForm", "Counts", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
var list = Assert.IsType<List<int>>(response.Value);
Assert.Equal(new[] { 10, 20 }, list);
// The on-disk override has been normalized to the native form.
var overrides = await _storage.GetStaticOverridesAsync("Pump-OldForm");
Assert.Single(overrides);
Assert.Equal("[10,20]", overrides["Counts"]);
}
/// <summary>
/// NJ-4: a NATIVE-form static override (<c>[10,20]</c>) is already canonical, so
/// load-time normalization must be a no-op — the on-disk value is unchanged
/// (idempotent: native → native is byte-identical, so no re-persist occurs).
/// </summary>
[Fact]
public async Task InstanceActor_NativeFormListOverride_NotRePersistedOnLoad()
{
await _storage.SetStaticOverrideAsync("Pump-Native", "Counts", "[10,20]");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-Native",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Counts", Value = "[1,2]",
DataType = "List", ElementDataType = "Int32"
}
]
};
var actor = CreateInstanceActor("Pump-Native", config);
await Task.Delay(1000);
actor.Tell(new GetAttributeRequest("corr-nat", "Pump-Native", "Counts", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
var list = Assert.IsType<List<int>>(response.Value);
Assert.Equal(new[] { 10, 20 }, list);
// The native value is left untouched on disk.
var overrides = await _storage.GetStaticOverridesAsync("Pump-Native");
Assert.Single(overrides);
Assert.Equal("[10,20]", overrides["Counts"]);
}
/// <summary>
/// NJ-4: a scalar static override is unaffected by the List normalization path —
/// its on-disk value is left exactly as stored (no native re-encode).
/// </summary>
[Fact]
public async Task InstanceActor_ScalarOverride_NotTouchedByListNormalization()
{
await _storage.SetStaticOverrideAsync("Pump-ScalarOf", "Temperature", "200.0");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-ScalarOf",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = "100.0", DataType = "Double" }
]
};
var actor = CreateInstanceActor("Pump-ScalarOf", config);
await Task.Delay(1000);
actor.Tell(new GetAttributeRequest("corr-sof", "Pump-ScalarOf", "Temperature", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("200.0", response.Value);
var overrides = await _storage.GetStaticOverridesAsync("Pump-ScalarOf");
Assert.Single(overrides);
Assert.Equal("200.0", overrides["Temperature"]);
}
/// <summary>
/// NJ-4: a malformed stored List override (truncated JSON) must NOT crash the
/// actor and must NOT be re-persisted — it loads with Bad quality (as today),
/// the actor stays alive, and the poison on-disk value is left unchanged.
/// </summary>
[Fact]
public async Task InstanceActor_MalformedListOverride_BadQuality_NotRePersisted()
{
await _storage.SetStaticOverrideAsync("Pump-BadOf", "Counts", "[\"a\"");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-BadOf",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Counts", Value = "[1,2]",
DataType = "List", ElementDataType = "Int32"
}
]
};
var actor = CreateInstanceActor("Pump-BadOf", config);
Watch(actor);
await Task.Delay(1000);
actor.Tell(new GetAttributeRequest("corr-bof", "Pump-BadOf", "Counts", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Bad", response.Quality);
Assert.Null(response.Value);
// The actor must still be alive — no crash from the normalization path.
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
// The malformed value must NOT have been re-persisted (left exactly as stored).
var overrides = await _storage.GetStaticOverridesAsync("Pump-BadOf");
Assert.Single(overrides);
Assert.Equal("[\"a\"", overrides["Counts"]);
}
}
@@ -269,6 +269,106 @@ public class InstanceServiceTests
_repoMock.Verify(r => r.AddInstanceConnectionBindingAsync(It.IsAny<InstanceConnectionBinding>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
// --- NJ-2: ElementDataType stamping on SetAttributeOverrideAsync ---
[Fact]
public async Task SetAttributeOverride_CreatePath_ListAttribute_StampsElementDataType()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateAttribute>
{
new("Tags")
{
IsLocked = false,
DataType = DataType.List,
ElementDataType = DataType.String
}
});
_repoMock.Setup(r => r.GetOverridesByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InstanceAttributeOverride>()); // no existing override → create path
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
InstanceAttributeOverride? captured = null;
_repoMock.Setup(r => r.AddInstanceAttributeOverrideAsync(It.IsAny<InstanceAttributeOverride>(), It.IsAny<CancellationToken>()))
.Callback<InstanceAttributeOverride, CancellationToken>((o, _) => captured = o);
var result = await _sut.SetAttributeOverrideAsync(1, "Tags", "[\"a\"]", "admin");
Assert.True(result.IsSuccess);
Assert.NotNull(captured);
Assert.Equal(DataType.String, captured.ElementDataType);
}
[Fact]
public async Task SetAttributeOverride_UpdatePath_ListAttribute_StampsElementDataType()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateAttribute>
{
new("Tags")
{
IsLocked = false,
DataType = DataType.List,
ElementDataType = DataType.String
}
});
var existingOverride = new InstanceAttributeOverride("Tags") { Id = 42, InstanceId = 1, OverrideValue = "[\"old\"]" };
_repoMock.Setup(r => r.GetOverridesByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InstanceAttributeOverride> { existingOverride }); // pre-existing → update path
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
InstanceAttributeOverride? captured = null;
_repoMock.Setup(r => r.UpdateInstanceAttributeOverrideAsync(It.IsAny<InstanceAttributeOverride>(), It.IsAny<CancellationToken>()))
.Callback<InstanceAttributeOverride, CancellationToken>((o, _) => captured = o);
var result = await _sut.SetAttributeOverrideAsync(1, "Tags", "[\"new\"]", "admin");
Assert.True(result.IsSuccess);
Assert.NotNull(captured);
Assert.Equal(DataType.String, captured.ElementDataType);
}
[Fact]
public async Task SetAttributeOverride_CreatePath_ScalarAttribute_ElementDataTypeIsNull()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateAttribute>
{
new("Threshold")
{
IsLocked = false,
DataType = DataType.Float,
ElementDataType = null
}
});
_repoMock.Setup(r => r.GetOverridesByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InstanceAttributeOverride>());
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
InstanceAttributeOverride? captured = null;
_repoMock.Setup(r => r.AddInstanceAttributeOverrideAsync(It.IsAny<InstanceAttributeOverride>(), It.IsAny<CancellationToken>()))
.Callback<InstanceAttributeOverride, CancellationToken>((o, _) => captured = o);
var result = await _sut.SetAttributeOverrideAsync(1, "Threshold", "3.14", "admin");
Assert.True(result.IsSuccess);
Assert.NotNull(captured);
Assert.Null(captured.ElementDataType);
}
[Fact]
public async Task AssignToArea_AreaInDifferentSite_ReturnsFailure()
{
@@ -259,6 +259,122 @@ public sealed class EntitySerializerTests
Assert.Equal("[\"a\",\"b\"]", rtAttr.Value);
}
private static BundleContentDto MakeContentWithListAttribute(
string value, DataType elementType)
{
var template = new TemplateDto(
Name: "Pump",
FolderName: null,
BaseTemplateName: null,
Description: null,
Attributes: new[]
{
new TemplateAttributeDto(
Name: "Tags",
Value: value,
DataType: DataType.List,
IsLocked: false,
Description: null,
DataSourceReference: null,
ElementDataType: elementType),
},
Alarms: Array.Empty<TemplateAlarmDto>(),
Scripts: Array.Empty<TemplateScriptDto>(),
Compositions: Array.Empty<TemplateCompositionDto>());
return new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: new[] { template },
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
}
[Fact]
public void Import_normalizes_old_form_Int32_list_value_to_native_json()
{
// Pre-native bundle: quoted Int32 list elements.
var dto = MakeContentWithListAttribute("[\"10\",\"20\"]", DataType.Int32);
var aggregate = new EntitySerializer().FromBundleContent(dto);
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
Assert.Equal(DataType.List, attr.DataType);
Assert.Equal(DataType.Int32, attr.ElementDataType);
// Imported native: numbers unquoted.
Assert.Equal("[10,20]", attr.Value);
}
[Fact]
public void Import_leaves_string_list_value_quoted()
{
var dto = MakeContentWithListAttribute("[\"a\",\"b\"]", DataType.String);
var aggregate = new EntitySerializer().FromBundleContent(dto);
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
Assert.Equal(DataType.List, attr.DataType);
Assert.Equal(DataType.String, attr.ElementDataType);
// Strings stay quoted in native form.
Assert.Equal("[\"a\",\"b\"]", attr.Value);
}
[Fact]
public void Import_leaves_malformed_list_value_unchanged_without_throwing()
{
// Truncated JSON array — Decode throws FormatException; the import must
// still succeed and carry the value through verbatim (DB normalizer is
// the backstop).
var dto = MakeContentWithListAttribute("[\"a\"", DataType.String);
var aggregate = new EntitySerializer().FromBundleContent(dto);
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
Assert.Equal("[\"a\"", attr.Value);
}
[Fact]
public void Import_leaves_scalar_attribute_value_unchanged()
{
var template = new TemplateDto(
Name: "Sensor",
FolderName: null,
BaseTemplateName: null,
Description: null,
Attributes: new[]
{
new TemplateAttributeDto(
Name: "Pressure",
Value: "42.0",
DataType: DataType.Double,
IsLocked: false,
Description: null,
DataSourceReference: null,
ElementDataType: null),
},
Alarms: Array.Empty<TemplateAlarmDto>(),
Scripts: Array.Empty<TemplateScriptDto>(),
Compositions: Array.Empty<TemplateCompositionDto>());
var dto = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: new[] { template },
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
var aggregate = new EntitySerializer().FromBundleContent(dto);
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
Assert.Equal(DataType.Double, attr.DataType);
Assert.Equal("42.0", attr.Value);
}
[Fact]
public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null()
{