From 7cbbc788ef25a67efc712b3a3268f799defafb5b Mon Sep 17 00:00:00 2001 From: Mariusz Pacek Date: Sat, 24 Oct 2026 05:18:18 +0200 Subject: [PATCH] fix(store): keep the strict restore file format with an optional otherApplied The strict reading from the store review rejected every file without the new otherApplied field, so after both fixes every restore would have gone to quarantine. A file record now marks the five real fields required and leaves otherApplied optional; a null setpoint or due time is still corrupt. Co-Authored-By: Claude Opus 5.5 --- .../adapters/store/JsonRestoreStore.java | 43 ++++++++++++++++--- .../adapters/store/JsonRestoreStoreTest.java | 14 +++++- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/main/java/home/gateway/adapters/store/JsonRestoreStore.java b/src/main/java/home/gateway/adapters/store/JsonRestoreStore.java index 681d94d..e874b6a 100644 --- a/src/main/java/home/gateway/adapters/store/JsonRestoreStore.java +++ b/src/main/java/home/gateway/adapters/store/JsonRestoreStore.java @@ -1,5 +1,7 @@ package home.gateway.adapters.store; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.MapperFeature; @@ -16,6 +18,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; import java.util.List; /** The pending restores as one JSON file, replaced atomically (ADR-005). */ @@ -25,9 +29,8 @@ public class JsonRestoreStore implements RestoreStore { private final ObjectMapper json = new ObjectMapper() .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - // Jackson would otherwise turn a missing or null number into 0, i.e. a restore of register 0. - .enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) - .enable(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES) + // Jackson would otherwise turn a null number into 0, i.e. a restore of register 0; missing + // fields are caught by the required flags on Stored. .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS); @@ -43,11 +46,15 @@ public class JsonRestoreStore implements RestoreStore { return List.of(); } try { - var restores = json.readValue(file.toFile(), new TypeReference>() { }); - if (restores == null || restores.contains(null)) { + var stored = json.readValue(file.toFile(), new TypeReference>() { }); + if (stored == null || stored.contains(null)) { throw new IOException("not a list of restores"); } - return restores; + var restores = new ArrayList(); + for (Stored one : stored) { + restores.add(one.restore()); + } + return List.copyOf(restores); } catch (IOException e) { throw quarantine(e); } @@ -64,7 +71,7 @@ public class JsonRestoreStore implements RestoreStore { var temp = file.resolveSibling(file.getFileName() + ".tmp"); try (var channel = FileChannel.open(temp, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { - channel.write(ByteBuffer.wrap(json.writeValueAsBytes(restores))); + channel.write(ByteBuffer.wrap(json.writeValueAsBytes(restores.stream().map(Stored::of).toList()))); // Without this a power cut after the move can leave an empty file on the SD card. channel.force(true); } @@ -80,4 +87,26 @@ public class JsonRestoreStore implements RestoreStore { // The file itself is already durable; only the rename may be lost. } } + + /** The file format: every field required except otherApplied, which only a lost reply sets. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private record Stored(@JsonProperty(required = true) String setpoint, + @JsonProperty(required = true) int register, + @JsonProperty(required = true) int originalValue, + @JsonProperty(required = true) int appliedValue, + @JsonProperty(required = true) Instant due, + Integer otherApplied) { + + static Stored of(Restore r) { + return new Stored(r.setpoint(), r.register(), r.originalValue(), r.appliedValue(), r.due(), + r.otherApplied()); + } + + Restore restore() throws IOException { + if (setpoint == null || due == null) { + throw new IOException("restore without setpoint or due time"); + } + return new Restore(setpoint, register, originalValue, appliedValue, due, otherApplied); + } + } } diff --git a/src/test/java/home/gateway/adapters/store/JsonRestoreStoreTest.java b/src/test/java/home/gateway/adapters/store/JsonRestoreStoreTest.java index f25113a..1b604b0 100644 --- a/src/test/java/home/gateway/adapters/store/JsonRestoreStoreTest.java +++ b/src/test/java/home/gateway/adapters/store/JsonRestoreStoreTest.java @@ -69,7 +69,9 @@ class JsonRestoreStoreTest { void anUnusableFileIsMovedAsideAndReportedNeverSilentlyDropped() throws IOException { var cases = List.of("{not json", "", "garbage \u0000\u0001", "null", "{}", "[null]", "[{\"setpoint\":\"x\"}]", restoreJson("4144", "\"5\""), restoreJson("null", "5"), - restoreJson("4144", "5").replace("}]", ",\"extra\":1}]")); + restoreJson("4144", "5").replace("}]", ",\"extra\":1}]"), + restoreJson("4144", "5").replace("\"x\"", "null"), + restoreJson("4144", "5").replace("\"2026-10-24T11:00:00Z\"", "null")); for (var content : cases) { var file = dir.resolve("restores.json"); Files.writeString(file, content); @@ -85,6 +87,16 @@ class JsonRestoreStoreTest { } } + @Test + void aLostReplyValueSurvivesSaveAndLoad() throws IOException { + var file = dir.resolve("restores.json"); + var restore = new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T11:00:00Z")).alsoApplied(47); + + new JsonRestoreStore(file).save(List.of(restore)); + + assertEquals(List.of(restore), new JsonRestoreStore(file).load()); + } + @Test void validFileWithAllFieldsStillLoads() throws IOException { var file = dir.resolve("restores.json");