Darkspyre.Persist 10.1.0

Prefix Reserved
dotnet add package Darkspyre.Persist --version 10.1.0
                    
NuGet\Install-Package Darkspyre.Persist -Version 10.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Darkspyre.Persist" Version="10.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Darkspyre.Persist" Version="10.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Darkspyre.Persist" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Darkspyre.Persist --version 10.1.0
                    
#r "nuget: Darkspyre.Persist, 10.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Darkspyre.Persist@10.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Darkspyre.Persist&version=10.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Darkspyre.Persist&version=10.1.0
                    
Install as a Cake Tool

<img src="https://github.com/DarkspyreStudios/PersistenceStore/raw/main/.github/banner.svg" alt="Darkspyre Persist">

Darkspyre Persist

Copyright (c) 2026 Darkspyre Studios, LLC. Licensed under the MIT License. Provided "as is", without warranty.

Darkspyre Persist stores complete files for an application: settings, images, documents, caches, exports. Application code addresses each file by a store name and a logical asset name, and never builds a filesystem path. The default provider keeps files in the current user's application-data directory and replaces each one atomically.

Purpose

Most applications write a handful of files to a per-user folder, and most of them get the details wrong in the same ways:

  • A crash mid-write leaves a truncated file, and the next launch cannot read its own settings.
  • A name taken from user input, such as ../../.ssh/config, escapes the intended folder.
  • The folder location differs per operating system, and each application computes it again.
  • Code that builds paths directly cannot move to another backend later.

Persist solves these once. IPersistStore is a storage-neutral contract over raw bytes. OsUserPersistStore implements it on the local filesystem:

  • Every save writes to a private staging file, flushes it to disk, and moves it into place. A reader sees the complete old asset or the complete new one, never a partial write.
  • Every name is validated. A name that could escape its store or alias another is rejected.
  • Stores are isolated. Two applications that use different store names never see each other's assets.
  • A missing asset reads as null, and an empty asset reads as an empty stream. The two are distinct.

Persist is not a credential vault. Store tokens, keys, and passwords with Darkspyre.SecureStore.

Publishing

The manually triggered .github/workflows/publish.yml workflow restores and runs Darkspyre.Persist.Tests in Release, then packs only Darkspyre.Persist. It publishes the version declared in Directory.Build.props to nuget.org. NuGet/login exchanges the GitHub Actions identity for a short-lived publishing key using the NuGet user darkspyre. The DarkspyreStudios NuGet organization owns the matching Trusted Publishing policy for PersistenceStore, publish.yml, and the exact package pattern Darkspyre.Persist. No environment is specified. A failed step stops publication. An existing package version is not overwritten or silently skipped. A successful publication completes policy activation.

Local source consumption

eng/SourceReferences.targets replaces a consumer's Darkspyre.Persist package reference with the src/Darkspyre.Persist/Darkspyre.Persist.csproj project in this worktree. The consumer imports it after declaring package references. Reference metadata is retained; runtime exclusions disable output copying, and compile exclusions disable the assembly reference.

Install

dotnet add package Darkspyre.Persist

The package targets .NET 10. The namespace is Darkspyre.Persist.

Quick start

using Darkspyre.Persist;

IPersistStore persistence = new OsUserPersistStore();

await persistence.SaveStringAsync("ExampleApp", "notes/welcome.txt", "Hello from Darkspyre.");

string? text = await persistence.ReadStringAsync("ExampleApp", "notes/welcome.txt");

On macOS this writes ~/Library/Application Support/Darkspyre Studios/ExampleApp/notes/welcome.txt. Windows and Linux use their own per-user application-data directory.

With a service container, register the provider as a singleton:

services.AddSingleton<IPersistStore, OsUserPersistStore>();

Names

Every call takes two names.

storeName isolates one application or subsystem. It is a single segment, such as ExampleApp. Pick one value per application and keep it stable across releases, because it addresses every asset already saved.

assetName is a relative, /-delimited logical name, such as images/portrait.webp. The provider creates intermediate directories as needed.

Both names must be portable across operating systems. The provider rejects:

  • absolute names, backslashes, and empty segments
  • the segments . and ..
  • segments that begin or end with whitespace, or end with .
  • the characters < > : " | ? * and control characters
  • Windows device names such as CON, NUL, and COM1, with or without an extension

An invalid name throws ArgumentException. Do not rely on two names that differ only by letter case. Windows and macOS usually treat them as one file, and Linux treats them as two.

The interface

IPersistStore operates on streams:

Method Behavior
ExistsAsync(storeName, assetName) Reports whether the asset exists.
OpenReadAsync(storeName, assetName) Returns a readable stream, or null when the asset is missing. The caller disposes the stream, and must not assume it is seekable.
EnumerateAsync(storeName, assetNamePrefix) Returns every asset name that starts with the prefix, recursively, in ordinal order. An empty prefix lists the whole store.
SaveAsync(storeName, assetName, content) Creates or atomically replaces the asset with the bytes from the stream's current position. The caller keeps ownership of the stream.
DeleteAsync(storeName, assetName) Deletes the asset. Deleting a missing asset succeeds, and never creates directories.

PersistStoreExtensions adds whole-asset helpers over the same contract:

Method Behavior
SaveAsync(storeName, assetName, ReadOnlyMemory<byte>) Saves a byte buffer.
SaveStringAsync(storeName, assetName, string) Saves a string as UTF-8 without a byte-order mark.
ReadBytesAsync(storeName, assetName) Reads the asset into a byte array, or returns null.
ReadStringAsync(storeName, assetName) Reads the asset as UTF-8 text, or returns null.

Every method accepts an optional CancellationToken.

Examples

Application settings as JSON

using System.Text.Json;
using Darkspyre.Persist;

public sealed record Settings(string Theme, int FontSize);

public sealed class SettingsService(IPersistStore persistence)
{
    private const string StoreName = "ExampleApp";
    private const string AssetName = "settings.json";

    public async Task<Settings> LoadAsync()
    {
        string? json = await persistence.ReadStringAsync(StoreName, AssetName);
        return json is null
            ? new Settings("light", 12)
            : JsonSerializer.Deserialize<Settings>(json)!;
    }

    public Task SaveAsync(Settings settings) =>
        persistence.SaveStringAsync(StoreName, AssetName, JsonSerializer.Serialize(settings));
}

A crash during SaveAsync leaves the previous settings.json intact.

Streaming a large file

The stream methods copy data without loading the whole asset into memory:

await using (FileStream source = File.OpenRead(pickedFilePath))
{
    await persistence.SaveAsync("ExampleApp", "images/portrait.webp", source);
}

await using Stream? image = await persistence.OpenReadAsync("ExampleApp", "images/portrait.webp");
if (image is not null)
{
    await image.CopyToAsync(destination);
}

Listing and clearing a folder

EnumerateAsync matches the prefix as plain text. End the prefix with / to match one folder only. The prefix cache also matches cache-old/data.bin, and cache/ does not.

IReadOnlyList<string> cached = await persistence.EnumerateAsync("ExampleApp", "cache/");

foreach (string assetName in cached)
{
    await persistence.DeleteAsync("ExampleApp", assetName);
}

Testing against a temporary directory

new OsUserPersistStore(rootDirectory) roots every store beneath an explicit directory. A test uses it to exercise the real filesystem without touching the user's application data:

string root = Directory.CreateTempSubdirectory().FullName;
try
{
    IPersistStore persistence = new OsUserPersistStore(root);
    var settings = new SettingsService(persistence);

    await settings.SaveAsync(new Settings("dark", 14));

    Assert.Equal("dark", (await settings.LoadAsync()).Theme);
}
finally
{
    Directory.Delete(root, recursive: true);
}

A host that owns a different application-data root uses the same constructor.

Storage layout

OsUserPersistStore keeps each store beneath:

Environment.SpecialFolder.ApplicationData/Darkspyre Studios/<storeName>/<assetName>

Each store holds a .darkspyre-persistence directory for staging files. EnumerateAsync never returns it, and an asset name cannot use it. The layout is an implementation detail of this provider and is not part of IPersistStore.

Boundaries

  • Persist does not produce public or browser-loadable URLs. An application's delivery layer reads through IPersistStore and decides which bytes an authorized caller receives.
  • Persist is not a credential vault. Store secrets with Darkspyre.SecureStore.
  • Persist does not host a live database file. A database provider owns its own file. A consistent export or backup may be saved as a separate asset.

Errors

Situation Result
Reading a missing asset returns null
Deleting a missing asset succeeds
Enumerating a store that does not exist returns an empty list
Invalid store name or asset name ArgumentException
Content stream that cannot be read ArgumentException
Access denied, disk full, or another filesystem failure the underlying IOException or UnauthorizedAccessException
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
10.1.0 87 10/11/2026