Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
42 changes: 42 additions & 0 deletions AppDataStorage.Test/AppDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,48 @@
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);

Check warning on line 1013 in AppDataStorage.Test/AppDataTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_AppDataStorage&issues=AaGZsWu_1v_hAsJM8mkH&open=AaGZsWu_1v_hAsJM8mkH&pullRequest=360
TestAppData.SaveIfRequired();

Assert.Contains("edited through Get", await AppData.FileSystem.File.ReadAllTextAsync(settings.FilePath).ConfigureAwait(false));

Check warning on line 1016 in AppDataStorage.Test/AppDataTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_AppDataStorage&issues=AaGZsWu_1v_hAsJM8mkI&open=AaGZsWu_1v_hAsJM8mkI&pullRequest=360
}

[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);

Check warning on line 1027 in AppDataStorage.Test/AppDataTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_AppDataStorage&issues=AaGZsWu_1v_hAsJM8mkJ&open=AaGZsWu_1v_hAsJM8mkJ&pullRequest=360
settings.SaveIfRequired();

Assert.Contains("edited through LoadOrCreate", await AppData.FileSystem.File.ReadAllTextAsync(settings.FilePath).ConfigureAwait(false));

Check warning on line 1030 in AppDataStorage.Test/AppDataTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_AppDataStorage&issues=AaGZsWu_1v_hAsJM8mkK&open=AaGZsWu_1v_hAsJM8mkK&pullRequest=360
}

[TestMethod]
[DoNotParallelize]
public async Task TestSaveIfRequiredStaticMethod()
Expand Down
18 changes: 16 additions & 2 deletions AppDataStorage/AppData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -508,10 +508,10 @@
/// </summary>
#if NET9_0_OR_GREATER
[JsonIgnore]
public static Lock Lock { get; } = new();

Check warning on line 511 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 511 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 511 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 511 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.
#else
[JsonIgnore]
public static object Lock { get; } = new();

Check warning on line 514 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 514 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 514 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 514 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 514 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 514 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.
#endif

internal bool IsSaveQueued()
Expand Down Expand Up @@ -686,6 +686,15 @@
/// <summary>
/// Loads the app data from the file system or creates a new instance if the file does not exist.
/// </summary>
/// <remarks>
/// Every call reads the file again and returns a new instance. It is not the instance <see cref="Get"/>
/// 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 <see cref="QueueSave()"/> and
/// <see cref="SaveIfRequired()"/> act on <see cref="Get"/>, so they do not save this instance: call
/// <see cref="AppData.QueueSave{T}(T)"/>, <see cref="AppData.SaveIfRequired{T}(T)"/>
/// or <see cref="Save()"/> on it instead. Use <see cref="Get"/> throughout when the application wants
/// one shared copy.
/// </remarks>
/// <returns>The loaded or newly created app data instance.</returns>
public static T LoadOrCreate() => LoadOrCreate(subdirectory: null, fileName: null);

Expand All @@ -704,12 +713,17 @@
public static T LoadOrCreate(FileName? fileName) => LoadOrCreate(subdirectory: null, fileName);

/// <summary>
/// Queues a save operation for the current app data instance.
/// Queues a save operation for the <see cref="Get"/> singleton.
/// </summary>
/// <remarks>
/// This does not save an instance returned by <see cref="LoadOrCreate()"/> or its overloads, which are
/// separate copies. Call the instance's own <c>QueueSave()</c> for those.
/// </remarks>
public static void QueueSave() => Get().QueueSave();

/// <summary>
/// Saves the app data if required based on the debounce time and the last save time.
/// Saves the <see cref="Get"/> singleton if a save is queued and the debounce time has passed.
/// </summary>
/// <inheritdoc cref="QueueSave()" path="/remarks"/>
public static void SaveIfRequired() => Get().SaveIfRequired();
}
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,14 @@ public class MyAppData : AppData<MyAppData>
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
Expand All @@ -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<T>`
- `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`
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading