diff --git a/.gitignore b/.gitignore index dc0470a..e043c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,11 @@ PublishScripts/ **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ +# and except a Unity project's Packages/, which is source: Unity's package manifest and its +# resolved lock file are both meant to be committed, and a NuGet restore folder never contains +# a file by either name. +!**/[Pp]ackages/manifest.json +!**/[Pp]ackages/packages-lock.json # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files @@ -651,3 +656,16 @@ Temporary Items # ImGui.ini files imgui.ini + +# Game engine projects +# +# Godot: the import cache, and the mono/temp bin+obj a C# build writes. +.godot/ + +# Unity: .meta files are source, not the Visual Studio C++ build artifact that the `*.meta` rule +# further up targets. Unity generates one per asset and it carries the GUID that scenes, prefabs +# and serialized references point at, so ignoring them gives every clone fresh GUIDs and silently +# breaks those references - including for a plug-in whose .dll is itself a build output. This +# negation has to come after that rule to win, and is scoped to the asset tree so the Visual +# Studio artifact stays ignored everywhere else. +!**/[Aa]ssets/**/*.meta diff --git a/AppDataStorage.Test/AppDataTests.cs b/AppDataStorage.Test/AppDataTests.cs index 4ee2a36..ff096f1 100644 --- a/AppDataStorage.Test/AppDataTests.cs +++ b/AppDataStorage.Test/AppDataTests.cs @@ -988,6 +988,48 @@ public void TestQueueSaveStaticMethod() Assert.IsGreaterThanOrEqualTo(beforeQueueTime, appData.SaveQueuedTime, "SaveQueuedTime was not set correctly."); } + // ktsu-dev/AppDataStorage#330: the docs describe LoadOrCreate() and Get() as separate instances, and + // the static QueueSave()/SaveIfRequired() as acting on Get() only. These pin that contract, and that + // each documented save path reaches disk. + [TestMethod] + [DoNotParallelize] + public void TestLoadOrCreateReturnsAnInstanceSeparateFromGet() + { + TestAppData singleton = TestAppData.Get(); + TestAppData copy = TestAppData.LoadOrCreate(); + + Assert.AreNotSame(singleton, copy, "LoadOrCreate() is documented as returning a new instance, not the Get() singleton."); + Assert.AreEqual(singleton.FilePath, copy.FilePath, "Both instances are documented as using the same file."); + } + + [TestMethod] + [DoNotParallelize] + public async Task TestEditOnGetSavedThroughStaticQueueSaveReachesDisk() + { + TestAppData settings = TestAppData.Get(); + settings.Data = "edited through Get"; + + TestAppData.QueueSave(); + await Task.Delay(settings.SaveDebounceTime + TimeSpan.FromMilliseconds(100)).ConfigureAwait(false); + TestAppData.SaveIfRequired(); + + Assert.Contains("edited through Get", await AppData.FileSystem.File.ReadAllTextAsync(settings.FilePath).ConfigureAwait(false)); + } + + [TestMethod] + [DoNotParallelize] + public async Task TestEditOnLoadOrCreateSavedThroughItsOwnQueueSaveReachesDisk() + { + using TestAppData settings = TestAppData.LoadOrCreate(); + settings.Data = "edited through LoadOrCreate"; + + settings.QueueSave(); + await Task.Delay(settings.SaveDebounceTime + TimeSpan.FromMilliseconds(100)).ConfigureAwait(false); + settings.SaveIfRequired(); + + Assert.Contains("edited through LoadOrCreate", await AppData.FileSystem.File.ReadAllTextAsync(settings.FilePath).ConfigureAwait(false)); + } + [TestMethod] [DoNotParallelize] public async Task TestSaveIfRequiredStaticMethod() diff --git a/AppDataStorage/AppData.cs b/AppDataStorage/AppData.cs index 0abca13..067097d 100644 --- a/AppDataStorage/AppData.cs +++ b/AppDataStorage/AppData.cs @@ -686,6 +686,15 @@ private static T LoadOrCreate(AbsoluteDirectoryPath? root, RelativeDirectoryPath /// /// Loads the app data from the file system or creates a new instance if the file does not exist. /// + /// + /// Every call reads the file again and returns a new instance. It is not the instance + /// returns, even though both use the same file, so an edit made on one is not seen by the other and + /// whichever saves last overwrites the file. The static and + /// act on , so they do not save this instance: call + /// , + /// or on it instead. Use throughout when the application wants + /// one shared copy. + /// /// The loaded or newly created app data instance. public static T LoadOrCreate() => LoadOrCreate(subdirectory: null, fileName: null); @@ -704,12 +713,17 @@ private static T LoadOrCreate(AbsoluteDirectoryPath? root, RelativeDirectoryPath public static T LoadOrCreate(FileName? fileName) => LoadOrCreate(subdirectory: null, fileName); /// - /// Queues a save operation for the current app data instance. + /// Queues a save operation for the singleton. /// + /// + /// This does not save an instance returned by or its overloads, which are + /// separate copies. Call the instance's own QueueSave() for those. + /// public static void QueueSave() => Get().QueueSave(); /// - /// Saves the app data if required based on the debounce time and the last save time. + /// Saves the singleton if a save is queued and the debounce time has passed. /// + /// public static void SaveIfRequired() => Get().SaveIfRequired(); } diff --git a/CLAUDE.md b/CLAUDE.md index d591995..06bc7c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,10 +51,14 @@ public class MyAppData : AppData public string Setting { get; set; } = "default"; } -// Load or create, then use static accessor -var data = MyAppData.LoadOrCreate(); -var same = MyAppData.Get(); // Singleton-like access -data.Save(); +// The shared instance: static QueueSave()/SaveIfRequired() act on this one +var data = MyAppData.Get(); +data.Setting = "changed"; +MyAppData.QueueSave(); + +// A separate copy, read from the same file: not the Get() instance, so save it through itself +var copy = MyAppData.LoadOrCreate(); +copy.Save(); ``` ### Key Implementation Details @@ -66,6 +70,7 @@ data.Save(); - Automatic backup files (`.bk` suffix) with timestamped collision handling - Safe write pattern: write to `.tmp`, backup existing, move `.tmp` to final - Lazy singleton access via `Get()` backed by `Lazy` +- `LoadOrCreate()` reads the file into a new instance on every call and never returns the `Get()` singleton, so the two do not see each other's edits. The static `QueueSave()` / `SaveIfRequired()` act on `Get()` only - `LoadOrCreate()` overloads support custom subdirectories and file names; `LoadOrCreateIn()` takes an absolute root directory - Corrupt file recovery: falls back to `.bk` backup, then creates a fresh instance - Process exit handler ensures queued saves are flushed via `IDisposable` diff --git a/README.md b/README.md index f099826..8a8f919 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,12 @@ var sameSettings = MySettings.Get(); Console.WriteLine(sameSettings.Theme); // "dark" ``` +`Get()` and `LoadOrCreate()` are not the same object. `LoadOrCreate()` reads the file into a new instance +every time it is called, so an edit made on that instance is not seen by `Get()`, and whichever of the two +saves last overwrites the file. The static `MySettings.QueueSave()` and `MySettings.SaveIfRequired()` act on +`Get()`. To save an instance from `LoadOrCreate()`, call `Save()`, `QueueSave()` or `SaveIfRequired()` on that +instance. Use `Get()` throughout when the application wants one shared copy. + ### Saving Data Modify properties and call `Save()` to persist changes immediately.