From e3cddc15b210fa804b092cd2e5322cc583ba3ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:32:34 +0000 Subject: [PATCH 1/3] Store the new instance in FileSystem and Temp RetrieveOrCreateAsync Both providers declared their own RetrieveOrCreateAsync that returned a default without storing it, so the call persisted on InMemory, ConfigHome and DataHome but not on these two. Store it as the interface documents. Fixes ktsu-dev/Essentials#55 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VPis8BNX5ETFM2XytgJCz7 --- .../FileSystemPersistenceProvider.cs | 12 ++++++- .../TempPersistenceProvider.cs | 12 ++++++- Essentials.Tests/PersistenceProviderTests.cs | 36 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs b/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs index 102a155..baef1ee 100644 --- a/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs +++ b/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs @@ -121,7 +121,17 @@ public async Task StoreAsync(TKey key, T obj, CancellationToken cancellationT public async Task RetrieveOrCreateAsync(TKey key, CancellationToken cancellationToken = default) where T : new() { T? obj = await RetrieveAsync(key, cancellationToken).ConfigureAwait(false); - return obj ?? new T(); + if (obj is not null) + { + return obj; + } + + // Stored before it is returned, as the interface documents and the other providers do: callers + // bootstrap a record with this, and one that was never written comes back as a fresh default on + // every call. + T newInstance = new(); + await StoreAsync(key, newInstance, cancellationToken).ConfigureAwait(false); + return newInstance; } /// diff --git a/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs b/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs index fa79d96..998a2e7 100644 --- a/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs +++ b/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs @@ -118,7 +118,17 @@ public async Task StoreAsync(TKey key, T obj, CancellationToken cancellationT public async Task RetrieveOrCreateAsync(TKey key, CancellationToken cancellationToken = default) where T : new() { T? obj = await RetrieveAsync(key, cancellationToken).ConfigureAwait(false); - return obj ?? new T(); + if (obj is not null) + { + return obj; + } + + // Stored before it is returned, as the interface documents and the other providers do: callers + // bootstrap a record with this, and one that was never written comes back as a fresh default on + // every call. + T newInstance = new(); + await StoreAsync(key, newInstance, cancellationToken).ConfigureAwait(false); + return newInstance; } /// diff --git a/Essentials.Tests/PersistenceProviderTests.cs b/Essentials.Tests/PersistenceProviderTests.cs index 556ba98..f94bc11 100644 --- a/Essentials.Tests/PersistenceProviderTests.cs +++ b/Essentials.Tests/PersistenceProviderTests.cs @@ -516,6 +516,42 @@ public void Temp_Properties() } } + // --- Cross-provider contract --- + + [TestMethod] + public async Task RetrieveOrCreate_Stores_The_New_Instance_On_Every_Provider() + { + // Called through the interface, as consumers do: FileSystem and Temp declared their own public + // RetrieveOrCreateAsync that returned a default without storing it, so the same call persisted on + // InMemory, ConfigHome and DataHome but not on these two. + string fileSystemDir = Path.Combine(Path.GetTempPath(), "PersistenceTests_FS_" + Guid.NewGuid().ToString("N")[..8]); + NativeFileSystemProvider fs = new(); + JsonSerializationProvider serializer = new(); + using TempPersistenceProvider temp = new(fs, serializer, "PersistenceTests_Temp_" + Guid.NewGuid().ToString("N")[..8]); + + try + { + IPersistenceProvider[] providers = + [ + CreatePersistence(), + new FileSystemPersistenceProvider(fs, serializer, fileSystemDir), + temp, + ]; + + foreach (IPersistenceProvider persistence in providers) + { + TestData created = await persistence.RetrieveOrCreateAsync("bootstrap", TestContext.CancellationToken).ConfigureAwait(false); + Assert.IsNotNull(created); + Assert.IsTrue(await persistence.ExistsAsync("bootstrap", TestContext.CancellationToken).ConfigureAwait(false), $"{persistence.ProviderName} did not store the new instance"); + } + } + finally + { + CleanupDirectory(fileSystemDir); + temp.CleanupDirectory(); + } + } + public sealed class TestData { public string Name { get; set; } = string.Empty; From 3b181d942879eb6d4d224637097876248758687b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:37:09 +0000 Subject: [PATCH 2/3] Build the contract test's temp directory with Path.Join Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VPis8BNX5ETFM2XytgJCz7 --- Essentials.Tests/PersistenceProviderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Essentials.Tests/PersistenceProviderTests.cs b/Essentials.Tests/PersistenceProviderTests.cs index f94bc11..8d2cc10 100644 --- a/Essentials.Tests/PersistenceProviderTests.cs +++ b/Essentials.Tests/PersistenceProviderTests.cs @@ -524,7 +524,7 @@ public async Task RetrieveOrCreate_Stores_The_New_Instance_On_Every_Provider() // Called through the interface, as consumers do: FileSystem and Temp declared their own public // RetrieveOrCreateAsync that returned a default without storing it, so the same call persisted on // InMemory, ConfigHome and DataHome but not on these two. - string fileSystemDir = Path.Combine(Path.GetTempPath(), "PersistenceTests_FS_" + Guid.NewGuid().ToString("N")[..8]); + string fileSystemDir = Path.Join(Path.GetTempPath(), "PersistenceTests_FS_" + Guid.NewGuid().ToString("N")[..8]); NativeFileSystemProvider fs = new(); JsonSerializationProvider serializer = new(); using TempPersistenceProvider temp = new(fs, serializer, "PersistenceTests_Temp_" + Guid.NewGuid().ToString("N")[..8]); From 05ac1446c127ff612fe1d2de77200f89360a0eea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:06:43 +0000 Subject: [PATCH 3/3] Share one RetrieveOrCreateAsync body between FileSystem and Temp Both providers declare the member publicly, which hides the interface default, and had each restated it. Move the body to a linked Shared helper so the two cannot drift apart again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VPis8BNX5ETFM2XytgJCz7 --- CLAUDE.md | 1 + ...als.PersistenceProviders.FileSystem.csproj | 4 ++ .../FileSystemPersistenceProvider.cs | 17 +------ ...ssentials.PersistenceProviders.Temp.csproj | 4 ++ .../TempPersistenceProvider.cs | 17 +------ Shared/PersistenceDefaults.cs | 44 +++++++++++++++++++ 6 files changed, 57 insertions(+), 30 deletions(-) create mode 100644 Shared/PersistenceDefaults.cs diff --git a/CLAUDE.md b/CLAUDE.md index 38377ed..672be02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ This is a .NET library (`ktsu.Essentials`) providing high-performance interfaces - `Shared/DeflateBound.cs` - zlib's `deflateBound` plus container overhead, linked into the Deflate, Gzip and ZLib providers as their `GetMaxCompressedLength` - `Shared/SpecialFunctions.cs` - Incomplete gamma, incomplete beta, error function and normal quantile, linked into the Normal, LogNormal, Binomial and Poisson providers rather than placed in the interfaces-only package - `Shared/DistributionArguments.cs` - Parameter validation shared by every distribution provider, linked in the same way +- `Shared/PersistenceDefaults.cs` - The `IPersistenceProvider` default `RetrieveOrCreateAsync` body, linked into the FileSystem and Temp providers, which declare that member themselves and so hide the default - `Essentials/ISerializationProvider.cs` - Object serialization/deserialization interface - `Essentials/ISerializationOptions.cs` - Configurable serialization options (naming, inclusion, boxing policies) - `Essentials/ICacheProvider.cs` - Generic cache interface with expiration and get-or-add diff --git a/Essentials.PersistenceProviders.FileSystem/Essentials.PersistenceProviders.FileSystem.csproj b/Essentials.PersistenceProviders.FileSystem/Essentials.PersistenceProviders.FileSystem.csproj index f7ce966..2f1340e 100644 --- a/Essentials.PersistenceProviders.FileSystem/Essentials.PersistenceProviders.FileSystem.csproj +++ b/Essentials.PersistenceProviders.FileSystem/Essentials.PersistenceProviders.FileSystem.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs b/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs index baef1ee..62d9de2 100644 --- a/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs +++ b/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs @@ -118,21 +118,8 @@ public async Task StoreAsync(TKey key, T obj, CancellationToken cancellationT } /// - public async Task RetrieveOrCreateAsync(TKey key, CancellationToken cancellationToken = default) where T : new() - { - T? obj = await RetrieveAsync(key, cancellationToken).ConfigureAwait(false); - if (obj is not null) - { - return obj; - } - - // Stored before it is returned, as the interface documents and the other providers do: callers - // bootstrap a record with this, and one that was never written comes back as a fresh default on - // every call. - T newInstance = new(); - await StoreAsync(key, newInstance, cancellationToken).ConfigureAwait(false); - return newInstance; - } + public Task RetrieveOrCreateAsync(TKey key, CancellationToken cancellationToken = default) where T : new() + => PersistenceDefaults.RetrieveOrCreateAsync(this, key, cancellationToken); /// public Task ExistsAsync(TKey key, CancellationToken cancellationToken = default) diff --git a/Essentials.PersistenceProviders.Temp/Essentials.PersistenceProviders.Temp.csproj b/Essentials.PersistenceProviders.Temp/Essentials.PersistenceProviders.Temp.csproj index f7ce966..2f1340e 100644 --- a/Essentials.PersistenceProviders.Temp/Essentials.PersistenceProviders.Temp.csproj +++ b/Essentials.PersistenceProviders.Temp/Essentials.PersistenceProviders.Temp.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs b/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs index 998a2e7..d0ad67f 100644 --- a/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs +++ b/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs @@ -115,21 +115,8 @@ public async Task StoreAsync(TKey key, T obj, CancellationToken cancellationT } /// - public async Task RetrieveOrCreateAsync(TKey key, CancellationToken cancellationToken = default) where T : new() - { - T? obj = await RetrieveAsync(key, cancellationToken).ConfigureAwait(false); - if (obj is not null) - { - return obj; - } - - // Stored before it is returned, as the interface documents and the other providers do: callers - // bootstrap a record with this, and one that was never written comes back as a fresh default on - // every call. - T newInstance = new(); - await StoreAsync(key, newInstance, cancellationToken).ConfigureAwait(false); - return newInstance; - } + public Task RetrieveOrCreateAsync(TKey key, CancellationToken cancellationToken = default) where T : new() + => PersistenceDefaults.RetrieveOrCreateAsync(this, key, cancellationToken); /// public Task ExistsAsync(TKey key, CancellationToken cancellationToken = default) diff --git a/Shared/PersistenceDefaults.cs b/Shared/PersistenceDefaults.cs new file mode 100644 index 0000000..5545b9b --- /dev/null +++ b/Shared/PersistenceDefaults.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials; + +using System.Threading; +using System.Threading.Tasks; + +/// +/// The interface default bodies a persistence provider has to repeat when it declares the member itself. +/// +/// +/// Linked into the FileSystem and Temp providers rather than placed in the interfaces package, following +/// DistributionArguments. Both declare a public RetrieveOrCreateAsync of their own, which +/// hides the default body, so the documented behaviour has to be +/// restated; restating it once here keeps the two from drifting apart again. +/// +internal static class PersistenceDefaults +{ + /// + /// Retrieves the object stored under , or creates, stores and returns a new one. + /// + /// The type used to identify stored objects. + /// The type of object to retrieve or create. + /// The provider to read from and store into. + /// The key of the object. + /// A token to cancel the operation. + /// The stored object, or the new instance now stored under . + internal static async Task RetrieveOrCreateAsync(IPersistenceProvider provider, TKey key, CancellationToken cancellationToken) + where TKey : notnull + where T : new() + { + T? existing = await provider.RetrieveAsync(key, cancellationToken).ConfigureAwait(false); + if (existing is not null) + { + return existing; + } + + // Stored before it is returned, as the interface documents: callers bootstrap a record with this, + // and one that was never written comes back as a fresh default on every call. + T newInstance = new(); + await provider.StoreAsync(key, newInstance, cancellationToken).ConfigureAwait(false); + return newInstance; + } +}