diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c895cf7..ba94e32 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,8 +11,9 @@ on:
- '**'
jobs:
+ # ── Modern .NET (net8 / net9 / net10) ────────────────────────────────────────
test:
- name: Build & Test
+ name: Build & Test (modern .NET)
runs-on: ubuntu-latest
steps:
@@ -28,10 +29,68 @@ jobs:
10.x
- name: Restore dependencies
- run: dotnet restore CacheWeave.slnx
+ run: dotnet restore CacheWeave.slnx --ignore-failed-sources
- - name: Build
- run: dotnet build CacheWeave.slnx --configuration Release --no-restore
+ - name: Build (modern .NET projects only)
+ run: |
+ for proj in \
+ src/CacheWeave.Core/CacheWeave.Core.csproj \
+ src/CacheWeave.Redis/CacheWeave.Redis.csproj \
+ src/CacheWeave.InMemory/CacheWeave.InMemory.csproj \
+ src/CacheWeave.DistributedCache/CacheWeave.DistributedCache.csproj \
+ src/CacheWeave.SQLite/CacheWeave.SQLite.csproj \
+ src/CacheWeave.DynamoDB/CacheWeave.DynamoDB.csproj \
+ src/CacheWeave.Memcached/CacheWeave.Memcached.csproj \
+ src/CacheWeave.NCache/CacheWeave.NCache.csproj \
+ src/CacheWeave.Faster/CacheWeave.Faster.csproj \
+ tests/CacheWeave.Tests/CacheWeave.Tests.csproj; do
+ dotnet build "$proj" --configuration Release --no-restore
+ done
- name: Test
- run: dotnet test CacheWeave.slnx --configuration Release --no-build --verbosity normal
+ run: >
+ dotnet test tests/CacheWeave.Tests/CacheWeave.Tests.csproj
+ --configuration Release
+ --no-build
+ --verbosity normal
+
+ # ── .NET Framework 4.8 (CacheWeave.Legacy) ───────────────────────────────────
+ test-net48:
+ name: Build & Test (net48 / CacheWeave.Legacy)
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.x
+ 9.x
+ 10.x
+
+ - name: Restore dependencies
+ shell: pwsh
+ run: dotnet restore src/CacheWeave.Legacy/CacheWeave.Legacy.csproj
+
+ - name: Restore test dependencies
+ shell: pwsh
+ run: dotnet restore tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj
+
+ - name: Build CacheWeave.Legacy
+ shell: pwsh
+ run: dotnet build src/CacheWeave.Legacy/CacheWeave.Legacy.csproj --configuration Release --no-restore
+
+ - name: Build CacheWeave.Legacy.Tests
+ shell: pwsh
+ run: dotnet build tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj --configuration Release --no-restore
+
+ - name: Test (net48)
+ shell: pwsh
+ run: >
+ dotnet test tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj
+ --configuration Release
+ --no-build
+ --verbosity normal
diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml
index 5185bc9..d33d97f 100644
--- a/.github/workflows/publish-nuget.yml
+++ b/.github/workflows/publish-nuget.yml
@@ -18,7 +18,8 @@ on:
jobs:
publish:
name: Build, Test & Publish
- runs-on: ubuntu-latest
+ # Windows is required to build and test the net48 CacheWeave.Legacy project
+ runs-on: windows-latest
permissions:
contents: write # required to create GitHub Releases
@@ -39,20 +40,20 @@ jobs:
- name: Resolve version and release notes
id: meta
+ shell: pwsh
run: |
- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
- VERSION="${{ inputs.version }}"
- NOTES="${{ inputs.release_notes }}"
- else
+ if ("${{ github.event_name }}" -eq "workflow_dispatch") {
+ $version = "${{ inputs.version }}"
+ $notes = "${{ inputs.release_notes }}"
+ } else {
# Strip leading 'v' from tag name (v1.0.1 → 1.0.1)
- VERSION="${GITHUB_REF_NAME#v}"
- # Read the body of the annotated tag (everything after the first blank line)
- NOTES="$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}")"
- fi
- echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
- # Write notes to a file to avoid shell quoting issues with multiline text
- printf '%s' "$NOTES" > /tmp/release_notes.md
- echo "Publishing version $VERSION"
+ $version = "$env:GITHUB_REF_NAME" -replace '^v', ''
+ # Read the body of the annotated tag
+ $notes = git tag -l --format='%(contents)' "$env:GITHUB_REF_NAME"
+ }
+ "VERSION=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
+ $notes | Out-File -FilePath "$env:TEMP\release_notes.md" -Encoding utf8
+ Write-Host "Publishing version $version"
- name: Restore dependencies
run: dotnet restore CacheWeave.slnx
@@ -64,35 +65,45 @@ jobs:
--no-restore
-p:Version=${{ steps.meta.outputs.VERSION }}
- - name: Test
+ - name: Test (modern .NET — ubuntu-compatible projects)
run: >
dotnet test CacheWeave.slnx
--configuration Release
--no-build
--verbosity normal
+ --filter "FullyQualifiedName!~CacheWeave.Legacy.Tests"
+
+ - name: Test (net48 — CacheWeave.Legacy.Tests)
+ run: >
+ dotnet test tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj
+ --configuration Release
+ --no-build
+ --verbosity normal
- name: Pack all packages
+ shell: pwsh
run: |
- NOTES="$(cat /tmp/release_notes.md)"
- PROJECTS=(
- src/CacheWeave.Core/CacheWeave.Core.csproj
- src/CacheWeave.Redis/CacheWeave.Redis.csproj
- src/CacheWeave.InMemory/CacheWeave.InMemory.csproj
- src/CacheWeave.DistributedCache/CacheWeave.DistributedCache.csproj
- src/CacheWeave.SQLite/CacheWeave.SQLite.csproj
- src/CacheWeave.DynamoDB/CacheWeave.DynamoDB.csproj
- src/CacheWeave.Memcached/CacheWeave.Memcached.csproj
- src/CacheWeave.NCache/CacheWeave.NCache.csproj
- src/CacheWeave.Faster/CacheWeave.Faster.csproj
+ $notes = Get-Content "$env:TEMP\release_notes.md" -Raw
+ $projects = @(
+ "src/CacheWeave.Core/CacheWeave.Core.csproj"
+ "src/CacheWeave.Redis/CacheWeave.Redis.csproj"
+ "src/CacheWeave.InMemory/CacheWeave.InMemory.csproj"
+ "src/CacheWeave.DistributedCache/CacheWeave.DistributedCache.csproj"
+ "src/CacheWeave.SQLite/CacheWeave.SQLite.csproj"
+ "src/CacheWeave.DynamoDB/CacheWeave.DynamoDB.csproj"
+ "src/CacheWeave.Memcached/CacheWeave.Memcached.csproj"
+ "src/CacheWeave.NCache/CacheWeave.NCache.csproj"
+ "src/CacheWeave.Faster/CacheWeave.Faster.csproj"
+ "src/CacheWeave.Legacy/CacheWeave.Legacy.csproj"
)
- for PROJECT in "${PROJECTS[@]}"; do
- dotnet pack "$PROJECT" \
- --configuration Release \
- --no-build \
- --output ./nupkg \
- -p:Version=${{ steps.meta.outputs.VERSION }} \
- -p:PackageReleaseNotes="$NOTES"
- done
+ foreach ($project in $projects) {
+ dotnet pack $project `
+ --configuration Release `
+ --no-build `
+ --output ./nupkg `
+ -p:Version=${{ steps.meta.outputs.VERSION }} `
+ "-p:PackageReleaseNotes=$notes"
+ }
- name: Publish to NuGet
run: >
@@ -104,8 +115,9 @@ jobs:
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: pwsh
run: >
- gh release create "$GITHUB_REF_NAME"
- --title "$GITHUB_REF_NAME"
- --notes-file /tmp/release_notes.md
+ gh release create "$env:GITHUB_REF_NAME"
+ --title "$env:GITHUB_REF_NAME"
+ --notes-file "$env:TEMP\release_notes.md"
--verify-tag
diff --git a/CacheWeave.slnx b/CacheWeave.slnx
index 9e0e58e..6e5c1c6 100644
--- a/CacheWeave.slnx
+++ b/CacheWeave.slnx
@@ -5,12 +5,14 @@
+
+
diff --git a/README.md b/README.md
index 337f1f7..8da6a4f 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ public async Task GetProducts([FromQuery] int page = 1)
| **Fault-tolerant** | All cache I/O wrapped in try/catch — Redis outage degrades to cache-miss behaviour, never a 500 |
| **7 providers** | Redis, InMemory, SQLite, NCache, DynamoDB, Memcached, FASTER KV |
| **Multi-target** | `net8.0`, `net9.0`, `net10.0` |
+| **.NET Framework 4.8** | `CacheWeave.Legacy` — programmatic caching for net48 without ASP.NET Core |
---
@@ -171,15 +172,76 @@ Multiple `[CacheWeaveEvict]` attributes are allowed on a single action.
## Providers
-| Package | Backing store | `RemoveByPrefix` |
+| Package | Backing store | `RemoveByPrefix` | Target frameworks |
+|---|---|---|---|
+| `CacheWeave.Redis` | StackExchange.Redis | Yes (SCAN + DEL) | net8/9/10 |
+| `CacheWeave.InMemory` | `IMemoryCache` | Yes (prefix scan) | net8/9/10 |
+| `CacheWeave.SQLite` | Microsoft.Data.Sqlite | Yes (SQL LIKE) | net8/9/10 |
+| `CacheWeave.NCache` | Alachisoft NCache | No | net8/9/10 |
+| `CacheWeave.DynamoDB` | AWS DynamoDB | No | net8/9/10 |
+| `CacheWeave.Memcached` | EnyimMemcachedCore | No | net8/9/10 |
+| `CacheWeave.Faster` | Microsoft FASTER KV | No | net8/9/10 |
+| `CacheWeave.Legacy` | Redis, InMemory, SQLite, DynamoDB, NCache | Depends on provider | **net48** |
+
+---
+
+## .NET Framework 4.8 Support (`CacheWeave.Legacy`)
+
+`CacheWeave.Legacy` brings provider-agnostic caching to .NET Framework 4.8 applications that cannot migrate to modern .NET. It exposes the same `ICacheWeaveService` programmatic API but **does not include ASP.NET Core filters** — there is no attribute-based caching on net48.
+
+### Install
+
+```bash
+dotnet add package CacheWeave.Legacy
+```
+
+### Register
+
+```csharp
+// Works with any DI container that supports Microsoft.Extensions.DependencyInjection
+services
+ .AddCacheWeave(options =>
+ {
+ options.GlobalKeyPrefix = "my-app";
+ options.DefaultExpiry = TimeSpan.FromMinutes(5);
+ options.Serializer = CacheWeaveSerializerType.SystemTextJson;
+ })
+ .AddCacheWeaveRedis("localhost:6379");
+ // or: .AddCacheWeaveInMemory()
+ // or: .AddCacheWeaveSQLite(o => o.DatabasePath = "cache.db")
+ // or: .AddCacheWeaveDynamoDb(dynamoClient)
+ // or: .AddCacheWeaveNCache("my-cache")
+```
+
+### Use
+
+```csharp
+public class ProductService
+{
+ private readonly ICacheWeaveService _cache;
+
+ public ProductService(ICacheWeaveService cache) => _cache = cache;
+
+ public Task GetAsync(int id) =>
+ _cache.GetOrSetAsync(
+ $"products:{id}",
+ ct => _repo.FindAsync(id, ct),
+ expiry: TimeSpan.FromMinutes(10));
+
+ public Task InvalidateAsync(int id) =>
+ _cache.InvalidateAsync($"products:{id}");
+}
+```
+
+### Provider capability on net48
+
+| Provider | `RemoveByPrefix` | Notes |
|---|---|---|
-| `CacheWeave.Redis` | StackExchange.Redis | Yes (SCAN + DEL) |
-| `CacheWeave.InMemory` | `IMemoryCache` | Yes (prefix scan) |
-| `CacheWeave.SQLite` | Microsoft.Data.Sqlite | Yes (SQL LIKE) |
-| `CacheWeave.NCache` | Alachisoft NCache | No |
-| `CacheWeave.DynamoDB` | AWS DynamoDB | No |
-| `CacheWeave.Memcached` | EnyimMemcachedCore | No |
-| `CacheWeave.Faster` | Microsoft FASTER KV | No |
+| Redis | Yes (SCAN + DEL) | |
+| InMemory | No | Uses `System.Runtime.Caching.MemoryCache` (in-box on net48) |
+| SQLite | Yes (SQL LIKE) | Uses `System.Data.SQLite.Core` |
+| DynamoDB | No | Client-side TTL enforcement included |
+| NCache | No | |
---
diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheCompressor.cs b/src/CacheWeave.Legacy/Abstractions/ICacheCompressor.cs
new file mode 100644
index 0000000..ae20e60
--- /dev/null
+++ b/src/CacheWeave.Legacy/Abstractions/ICacheCompressor.cs
@@ -0,0 +1,8 @@
+namespace CacheWeave.Legacy.Abstractions
+{
+ public interface ICacheCompressor
+ {
+ string Compress(string value);
+ string Decompress(string value);
+ }
+}
diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheProvider.cs b/src/CacheWeave.Legacy/Abstractions/ICacheProvider.cs
new file mode 100644
index 0000000..7e6949e
--- /dev/null
+++ b/src/CacheWeave.Legacy/Abstractions/ICacheProvider.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace CacheWeave.Legacy.Abstractions
+{
+ public interface ICacheProvider
+ {
+ Task GetAsync(string key, CancellationToken cancellationToken = default);
+ Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default);
+ Task RemoveAsync(string key, CancellationToken cancellationToken = default);
+ Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheProviderInner.cs b/src/CacheWeave.Legacy/Abstractions/ICacheProviderInner.cs
new file mode 100644
index 0000000..0115f0d
--- /dev/null
+++ b/src/CacheWeave.Legacy/Abstractions/ICacheProviderInner.cs
@@ -0,0 +1,6 @@
+namespace CacheWeave.Legacy.Abstractions
+{
+ public interface ICacheProviderInner : ICacheProvider
+ {
+ }
+}
diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheSerializer.cs b/src/CacheWeave.Legacy/Abstractions/ICacheSerializer.cs
new file mode 100644
index 0000000..f7adb3f
--- /dev/null
+++ b/src/CacheWeave.Legacy/Abstractions/ICacheSerializer.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace CacheWeave.Legacy.Abstractions
+{
+ public interface ICacheSerializer
+ {
+ string Serialize(T value);
+
+ [return: MaybeNull]
+ T Deserialize(string value);
+
+ string Serialize(object value, Type type);
+ object? Deserialize(string value, Type type);
+ }
+}
diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheStampedeProtector.cs b/src/CacheWeave.Legacy/Abstractions/ICacheStampedeProtector.cs
new file mode 100644
index 0000000..e3c289f
--- /dev/null
+++ b/src/CacheWeave.Legacy/Abstractions/ICacheStampedeProtector.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace CacheWeave.Legacy.Abstractions
+{
+ public interface ICacheStampedeProtector
+ {
+ [return: MaybeNull]
+ Task ExecuteAsync(
+ string key,
+ Func> factory,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheWeaveService.cs b/src/CacheWeave.Legacy/Abstractions/ICacheWeaveService.cs
new file mode 100644
index 0000000..2a249b7
--- /dev/null
+++ b/src/CacheWeave.Legacy/Abstractions/ICacheWeaveService.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace CacheWeave.Legacy.Abstractions
+{
+ public interface ICacheWeaveService
+ {
+ [return: MaybeNull]
+ Task GetOrSetAsync(string key, Func> factory, TimeSpan? expiry = null, CancellationToken cancellationToken = default);
+
+ [return: MaybeNull]
+ Task GetAsync(string key, CancellationToken cancellationToken = default);
+
+ Task SetAsync(string key, T value, TimeSpan? expiry = null, CancellationToken cancellationToken = default);
+ Task InvalidateAsync(string key, CancellationToken cancellationToken = default);
+ Task InvalidateByPrefixAsync(string prefix, CancellationToken cancellationToken = default);
+ Task InvalidateByPrefixesAsync(IEnumerable prefixes, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/CacheWeave.Legacy/CacheWeave.Legacy.csproj b/src/CacheWeave.Legacy/CacheWeave.Legacy.csproj
new file mode 100644
index 0000000..20bcfae
--- /dev/null
+++ b/src/CacheWeave.Legacy/CacheWeave.Legacy.csproj
@@ -0,0 +1,38 @@
+
+
+
+ net48
+ 8.0
+ enable
+ CacheWeave.Legacy
+ CacheWeave.Legacy
+ MIT
+ README.md
+ CacheWeave provider-agnostic caching for .NET Framework 4.8. Includes Redis, InMemory, DynamoDB, SQLite, and NCache providers without ASP.NET Core dependencies.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CacheWeave.Legacy/CacheWeaveOptions.cs b/src/CacheWeave.Legacy/CacheWeaveOptions.cs
new file mode 100644
index 0000000..5ae279b
--- /dev/null
+++ b/src/CacheWeave.Legacy/CacheWeaveOptions.cs
@@ -0,0 +1,28 @@
+using System;
+
+namespace CacheWeave.Legacy
+{
+ public sealed class CacheWeaveOptions
+ {
+ /// When false, all cache operations are no-ops.
+ public bool Enabled { get; set; } = true;
+
+ /// Separator used between cache key segments. Default is ":".
+ public string KeySeparator { get; set; } = ":";
+
+ /// Optional prefix prepended to every cache key.
+ public string? GlobalKeyPrefix { get; set; }
+
+ /// Optional version segment injected into every cache key.
+ public string? KeyVersion { get; set; }
+
+ /// Default TTL applied when no expiry is specified. Default is 300 seconds.
+ public TimeSpan? DefaultExpiry { get; set; } = TimeSpan.FromSeconds(300);
+
+ /// Serializer to use for cache values. Default is System.Text.Json.
+ public CacheWeaveSerializerType Serializer { get; set; } = CacheWeaveSerializerType.SystemTextJson;
+
+ /// When true, wraps the provider with GZip compression.
+ public bool EnableCompression { get; set; } = false;
+ }
+}
diff --git a/src/CacheWeave.Legacy/CacheWeaveSerializerType.cs b/src/CacheWeave.Legacy/CacheWeaveSerializerType.cs
new file mode 100644
index 0000000..5ba9ba1
--- /dev/null
+++ b/src/CacheWeave.Legacy/CacheWeaveSerializerType.cs
@@ -0,0 +1,8 @@
+namespace CacheWeave.Legacy
+{
+ public enum CacheWeaveSerializerType
+ {
+ SystemTextJson,
+ NewtonsoftJson
+ }
+}
diff --git a/src/CacheWeave.Legacy/Compression/CompressingCacheProvider.cs b/src/CacheWeave.Legacy/Compression/CompressingCacheProvider.cs
new file mode 100644
index 0000000..6d8b3ae
--- /dev/null
+++ b/src/CacheWeave.Legacy/Compression/CompressingCacheProvider.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Abstractions;
+
+namespace CacheWeave.Legacy.Compression
+{
+ ///
+ /// Decorator that wraps an with GZip compression.
+ ///
+ public sealed class CompressingCacheProvider : ICacheProvider
+ {
+ private readonly ICacheProviderInner _inner;
+ private readonly ICacheCompressor _compressor;
+
+ public CompressingCacheProvider(ICacheProviderInner inner, ICacheCompressor compressor)
+ {
+ _inner = inner;
+ _compressor = compressor;
+ }
+
+ public async Task GetAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var value = await _inner.GetAsync(key, cancellationToken).ConfigureAwait(false);
+ return value is null ? null : _compressor.Decompress(value);
+ }
+
+ public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
+ {
+ var compressed = _compressor.Compress(value);
+ return _inner.SetAsync(key, compressed, expiry, cancellationToken);
+ }
+
+ public Task RemoveAsync(string key, CancellationToken cancellationToken = default) =>
+ _inner.RemoveAsync(key, cancellationToken);
+
+ public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) =>
+ _inner.RemoveByPrefixAsync(prefix, cancellationToken);
+ }
+}
diff --git a/src/CacheWeave.Legacy/Compression/GZipCacheCompressor.cs b/src/CacheWeave.Legacy/Compression/GZipCacheCompressor.cs
new file mode 100644
index 0000000..3c9924c
--- /dev/null
+++ b/src/CacheWeave.Legacy/Compression/GZipCacheCompressor.cs
@@ -0,0 +1,34 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Text;
+using CacheWeave.Legacy.Abstractions;
+
+namespace CacheWeave.Legacy.Compression
+{
+ public sealed class GZipCacheCompressor : ICacheCompressor
+ {
+ private const CompressionLevel Level = CompressionLevel.Fastest;
+
+ public string Compress(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ using var output = new MemoryStream();
+ using (var gzip = new GZipStream(output, Level, leaveOpen: true))
+ {
+ gzip.Write(bytes, 0, bytes.Length);
+ }
+ return Convert.ToBase64String(output.ToArray());
+ }
+
+ public string Decompress(string value)
+ {
+ var bytes = Convert.FromBase64String(value);
+ using var input = new MemoryStream(bytes);
+ using var gzip = new GZipStream(input, CompressionMode.Decompress);
+ using var output = new MemoryStream();
+ gzip.CopyTo(output);
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+ }
+}
diff --git a/src/CacheWeave.Legacy/Extensions/ServiceCollectionExtensions.cs b/src/CacheWeave.Legacy/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..3b2e958
--- /dev/null
+++ b/src/CacheWeave.Legacy/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,151 @@
+using System;
+using Amazon.DynamoDBv2;
+using Alachisoft.NCache.Client;
+using CacheWeave.Legacy.Abstractions;
+using CacheWeave.Legacy.Compression;
+using CacheWeave.Legacy.Providers;
+using CacheWeave.Legacy.Serialization;
+using CacheWeave.Legacy.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using StackExchange.Redis;
+
+namespace CacheWeave.Legacy.Extensions
+{
+ public static class ServiceCollectionExtensions
+ {
+ ///
+ /// Registers CacheWeave core services. Call one of the provider extension methods
+ /// (e.g. ) after this to register a backing store.
+ ///
+ public static IServiceCollection AddCacheWeave(
+ this IServiceCollection services,
+ Action? configure = null)
+ {
+ var opts = new CacheWeaveOptions();
+ configure?.Invoke(opts);
+
+ services.Configure(o =>
+ {
+ o.Enabled = opts.Enabled;
+ o.KeySeparator = opts.KeySeparator;
+ o.GlobalKeyPrefix = opts.GlobalKeyPrefix;
+ o.KeyVersion = opts.KeyVersion;
+ o.DefaultExpiry = opts.DefaultExpiry;
+ o.Serializer = opts.Serializer;
+ o.EnableCompression = opts.EnableCompression;
+ });
+
+ // Serializer
+ services.TryAddSingleton(_ =>
+ opts.Serializer == CacheWeaveSerializerType.NewtonsoftJson
+ ? (ICacheSerializer)new NewtonsoftJsonCacheSerializer()
+ : new SystemTextJsonCacheSerializer());
+
+ // Stampede protector
+ services.TryAddSingleton();
+
+ // Compressor
+ services.TryAddSingleton();
+
+ // ICacheProvider factory — wraps inner provider with compression if enabled
+ services.AddSingleton(sp =>
+ {
+ if (!opts.Enabled)
+ return DisabledCacheProvider.Instance;
+
+ var inner = sp.GetService();
+ if (inner is null)
+ return DisabledCacheProvider.Instance;
+
+ if (!opts.EnableCompression)
+ return inner;
+
+ var compressor = sp.GetRequiredService();
+ return new CompressingCacheProvider(inner, compressor);
+ });
+
+ // High-level service
+ services.TryAddSingleton();
+
+ return services;
+ }
+
+ // ── Redis ────────────────────────────────────────────────────────────
+
+ /// Registers the Redis provider using a connection string.
+ public static IServiceCollection AddCacheWeaveRedis(
+ this IServiceCollection services,
+ string connectionString)
+ {
+ var configOpts = ConfigurationOptions.Parse(connectionString);
+ configOpts.AbortOnConnectFail = false;
+ services.TryAddSingleton(_ =>
+ ConnectionMultiplexer.Connect(configOpts));
+ services.TryAddSingleton();
+ return services;
+ }
+
+ /// Registers the Redis provider using an existing .
+ public static IServiceCollection AddCacheWeaveRedis(
+ this IServiceCollection services,
+ IConnectionMultiplexer multiplexer)
+ {
+ services.TryAddSingleton(multiplexer);
+ services.TryAddSingleton();
+ return services;
+ }
+
+ // ── InMemory ─────────────────────────────────────────────────────────
+
+ ///
+ /// Registers the in-memory provider backed by .
+ /// Note: prefix-based eviction is not supported.
+ ///
+ public static IServiceCollection AddCacheWeaveInMemory(this IServiceCollection services)
+ {
+ services.TryAddSingleton();
+ return services;
+ }
+
+ // ── DynamoDB ─────────────────────────────────────────────────────────
+
+ /// Registers the DynamoDB provider.
+ public static IServiceCollection AddCacheWeaveDynamoDb(
+ this IServiceCollection services,
+ IAmazonDynamoDB dynamoDbClient,
+ Action? configure = null)
+ {
+ services.TryAddSingleton(dynamoDbClient);
+ if (configure != null)
+ services.Configure(configure);
+ services.TryAddSingleton();
+ return services;
+ }
+
+ // ── SQLite ───────────────────────────────────────────────────────────
+
+ /// Registers the SQLite provider.
+ public static IServiceCollection AddCacheWeaveSQLite(
+ this IServiceCollection services,
+ Action? configure = null)
+ {
+ if (configure != null)
+ services.Configure(configure);
+ services.TryAddSingleton();
+ return services;
+ }
+
+ // ── NCache ───────────────────────────────────────────────────────────
+
+ /// Registers the NCache provider.
+ public static IServiceCollection AddCacheWeaveNCache(
+ this IServiceCollection services,
+ string cacheName)
+ {
+ services.TryAddSingleton(_ => CacheManager.GetCache(cacheName));
+ services.TryAddSingleton();
+ return services;
+ }
+ }
+}
diff --git a/src/CacheWeave.Legacy/Polyfills/NullableAttributes.cs b/src/CacheWeave.Legacy/Polyfills/NullableAttributes.cs
new file mode 100644
index 0000000..dfb2ad3
--- /dev/null
+++ b/src/CacheWeave.Legacy/Polyfills/NullableAttributes.cs
@@ -0,0 +1,23 @@
+// Polyfill for nullable flow attributes that are internal in .NET Framework 4.8's BCL
+// but required by C# 8 nullable reference type annotations.
+// These are only compiled when targeting net48; on net5+ the BCL provides them.
+
+namespace System.Diagnostics.CodeAnalysis
+{
+ /// Specifies that an output may be null even if the corresponding type disallows it.
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter |
+ AttributeTargets.Property | AttributeTargets.ReturnValue,
+ Inherited = false)]
+ internal sealed class MaybeNullAttribute : Attribute { }
+
+ /// Specifies that null is allowed as an input even if the corresponding type disallows it.
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property,
+ Inherited = false)]
+ internal sealed class AllowNullAttribute : Attribute { }
+
+ /// Specifies that an output will not be null even if the corresponding type allows it.
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter |
+ AttributeTargets.Property | AttributeTargets.ReturnValue,
+ Inherited = false)]
+ internal sealed class NotNullAttribute : Attribute { }
+}
diff --git a/src/CacheWeave.Legacy/Providers/DisabledCacheProvider.cs b/src/CacheWeave.Legacy/Providers/DisabledCacheProvider.cs
new file mode 100644
index 0000000..e5f3040
--- /dev/null
+++ b/src/CacheWeave.Legacy/Providers/DisabledCacheProvider.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Abstractions;
+
+namespace CacheWeave.Legacy.Providers
+{
+ ///
+ /// No-op provider used when caching is globally disabled via .
+ ///
+ public sealed class DisabledCacheProvider : ICacheProvider
+ {
+ public static readonly DisabledCacheProvider Instance = new DisabledCacheProvider();
+
+ private DisabledCacheProvider() { }
+
+ public Task GetAsync(string key, CancellationToken cancellationToken = default) =>
+ Task.FromResult(null);
+
+ public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) =>
+ Task.CompletedTask;
+
+ public Task RemoveAsync(string key, CancellationToken cancellationToken = default) =>
+ Task.CompletedTask;
+
+ public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) =>
+ Task.CompletedTask;
+ }
+}
diff --git a/src/CacheWeave.Legacy/Providers/DynamoDbCacheOptions.cs b/src/CacheWeave.Legacy/Providers/DynamoDbCacheOptions.cs
new file mode 100644
index 0000000..7462a1e
--- /dev/null
+++ b/src/CacheWeave.Legacy/Providers/DynamoDbCacheOptions.cs
@@ -0,0 +1,10 @@
+namespace CacheWeave.Legacy.Providers
+{
+ public sealed class DynamoDbCacheOptions
+ {
+ public string TableName { get; set; } = "CacheWeaveCache";
+ public string KeyAttribute { get; set; } = "CacheKey";
+ public string ValueAttribute { get; set; } = "CacheValue";
+ public string TtlAttribute { get; set; } = "ExpiresAt";
+ }
+}
diff --git a/src/CacheWeave.Legacy/Providers/DynamoDbCacheProvider.cs b/src/CacheWeave.Legacy/Providers/DynamoDbCacheProvider.cs
new file mode 100644
index 0000000..ee40052
--- /dev/null
+++ b/src/CacheWeave.Legacy/Providers/DynamoDbCacheProvider.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.DynamoDBv2;
+using Amazon.DynamoDBv2.Model;
+using CacheWeave.Legacy.Abstractions;
+using Microsoft.Extensions.Options;
+
+namespace CacheWeave.Legacy.Providers
+{
+ public sealed class DynamoDbCacheProvider : ICacheProviderInner
+ {
+ private readonly IAmazonDynamoDB _dynamoDb;
+ private readonly DynamoDbCacheOptions _options;
+
+ public DynamoDbCacheProvider(IAmazonDynamoDB dynamoDb, IOptions options)
+ {
+ _dynamoDb = dynamoDb;
+ _options = options.Value;
+ }
+
+ public async Task GetAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var request = new GetItemRequest
+ {
+ TableName = _options.TableName,
+ Key = new Dictionary
+ {
+ [_options.KeyAttribute] = new AttributeValue { S = key }
+ }
+ };
+
+ var response = await _dynamoDb.GetItemAsync(request, cancellationToken).ConfigureAwait(false);
+ if (!response.IsItemSet)
+ return null;
+
+ // Client-side TTL check — DynamoDB TTL deletion can lag up to 48h
+ if (response.Item.TryGetValue(_options.TtlAttribute, out var ttlAttr) &&
+ long.TryParse(ttlAttr.N, out var ttlUnix))
+ {
+ if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > ttlUnix)
+ {
+ await RemoveAsync(key, cancellationToken).ConfigureAwait(false);
+ return null;
+ }
+ }
+
+ return response.Item.TryGetValue(_options.ValueAttribute, out var valueAttr)
+ ? valueAttr.S
+ : null;
+ }
+
+ public async Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
+ {
+ var item = new Dictionary
+ {
+ [_options.KeyAttribute] = new AttributeValue { S = key },
+ [_options.ValueAttribute] = new AttributeValue { S = value }
+ };
+
+ if (expiry.HasValue)
+ {
+ var ttl = DateTimeOffset.UtcNow.Add(expiry.Value).ToUnixTimeSeconds();
+ item[_options.TtlAttribute] = new AttributeValue { N = ttl.ToString() };
+ }
+
+ await _dynamoDb.PutItemAsync(new PutItemRequest
+ {
+ TableName = _options.TableName,
+ Item = item
+ }, cancellationToken).ConfigureAwait(false);
+ }
+
+ public Task RemoveAsync(string key, CancellationToken cancellationToken = default) =>
+ _dynamoDb.DeleteItemAsync(new DeleteItemRequest
+ {
+ TableName = _options.TableName,
+ Key = new Dictionary
+ {
+ [_options.KeyAttribute] = new AttributeValue { S = key }
+ }
+ }, cancellationToken);
+
+ public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException(
+ "DynamoDB does not support prefix scans on partition keys. " +
+ "Use Redis or SQLite if you need prefix eviction.");
+ }
+}
diff --git a/src/CacheWeave.Legacy/Providers/InMemoryCacheProvider.cs b/src/CacheWeave.Legacy/Providers/InMemoryCacheProvider.cs
new file mode 100644
index 0000000..6497263
--- /dev/null
+++ b/src/CacheWeave.Legacy/Providers/InMemoryCacheProvider.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Runtime.Caching;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Abstractions;
+
+namespace CacheWeave.Legacy.Providers
+{
+ ///
+ /// In-memory cache provider using ,
+ /// which is available in-box on .NET Framework 4.8.
+ /// Note: prefix-based removal is not supported.
+ ///
+ public sealed class InMemoryCacheProvider : ICacheProviderInner, IDisposable
+ {
+ private readonly MemoryCache _cache;
+
+ public InMemoryCacheProvider() : this(new MemoryCache("CacheWeave")) { }
+
+ public InMemoryCacheProvider(MemoryCache cache)
+ {
+ _cache = cache;
+ }
+
+ public Task GetAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var value = _cache.Get(key) as string;
+ return Task.FromResult(value);
+ }
+
+ public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
+ {
+ var policy = new CacheItemPolicy();
+ if (expiry.HasValue)
+ policy.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(expiry.Value);
+
+ _cache.Set(key, value, policy);
+ return Task.CompletedTask;
+ }
+
+ public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
+ {
+ _cache.Remove(key);
+ return Task.CompletedTask;
+ }
+
+ public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException(
+ "Prefix-based removal is not supported by the InMemory provider on .NET Framework 4.8. " +
+ "Use Redis or SQLite if you need prefix eviction.");
+
+ public void Dispose() => _cache.Dispose();
+ }
+}
diff --git a/src/CacheWeave.Legacy/Providers/NCacheCacheProvider.cs b/src/CacheWeave.Legacy/Providers/NCacheCacheProvider.cs
new file mode 100644
index 0000000..b43858e
--- /dev/null
+++ b/src/CacheWeave.Legacy/Providers/NCacheCacheProvider.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Alachisoft.NCache.Client;
+using Alachisoft.NCache.Runtime.Caching;
+using CacheWeave.Legacy.Abstractions;
+
+namespace CacheWeave.Legacy.Providers
+{
+ public sealed class NCacheCacheProvider : ICacheProviderInner, IDisposable
+ {
+ private readonly ICache _cache;
+
+ public NCacheCacheProvider(ICache cache)
+ {
+ _cache = cache;
+ }
+
+ public Task GetAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var value = _cache.Get(key);
+ return Task.FromResult(value);
+ }
+
+ public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
+ {
+ var item = new CacheItem(value);
+ if (expiry.HasValue)
+ item.Expiration = new Expiration(ExpirationType.Absolute, expiry.Value);
+
+ _cache.Insert(key, item);
+ return Task.CompletedTask;
+ }
+
+ public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
+ {
+ _cache.Remove(key);
+ return Task.CompletedTask;
+ }
+
+ public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException(
+ "NCache does not support prefix-based removal in this provider. " +
+ "Use Redis or SQLite if you need prefix eviction.");
+
+ public void Dispose() => _cache.Dispose();
+ }
+}
diff --git a/src/CacheWeave.Legacy/Providers/RedisCacheProvider.cs b/src/CacheWeave.Legacy/Providers/RedisCacheProvider.cs
new file mode 100644
index 0000000..cbbb767
--- /dev/null
+++ b/src/CacheWeave.Legacy/Providers/RedisCacheProvider.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Abstractions;
+using StackExchange.Redis;
+
+namespace CacheWeave.Legacy.Providers
+{
+ public sealed class RedisCacheProvider : ICacheProviderInner
+ {
+ private readonly IConnectionMultiplexer _multiplexer;
+
+ public RedisCacheProvider(IConnectionMultiplexer multiplexer)
+ {
+ _multiplexer = multiplexer;
+ }
+
+ private IDatabase Db => _multiplexer.GetDatabase();
+
+ public async Task GetAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var value = await Db.StringGetAsync(key).ConfigureAwait(false);
+ return value.HasValue ? value.ToString() : null;
+ }
+
+ public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) =>
+ Db.StringSetAsync(key, value, expiry);
+
+ public Task RemoveAsync(string key, CancellationToken cancellationToken = default) =>
+ Db.KeyDeleteAsync(key);
+
+ public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
+ {
+ var db = Db;
+ foreach (var server in _multiplexer.GetServers())
+ {
+ if (server.IsReplica)
+ continue;
+
+ var batch = new List(250);
+ await foreach (var key in server.KeysAsync(pattern: $"{prefix}*", pageSize: 250).ConfigureAwait(false))
+ {
+ batch.Add(key);
+ if (batch.Count == 250)
+ {
+ await db.KeyDeleteAsync(batch.ToArray()).ConfigureAwait(false);
+ batch.Clear();
+ }
+ }
+
+ if (batch.Count > 0)
+ await db.KeyDeleteAsync(batch.ToArray()).ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/src/CacheWeave.Legacy/Providers/SQLiteCacheOptions.cs b/src/CacheWeave.Legacy/Providers/SQLiteCacheOptions.cs
new file mode 100644
index 0000000..c95ecd9
--- /dev/null
+++ b/src/CacheWeave.Legacy/Providers/SQLiteCacheOptions.cs
@@ -0,0 +1,10 @@
+using System.IO;
+
+namespace CacheWeave.Legacy.Providers
+{
+ public sealed class SQLiteCacheOptions
+ {
+ public string DatabasePath { get; set; } = "cacheweave.db";
+ public string TableName { get; set; } = "CacheEntries";
+ }
+}
diff --git a/src/CacheWeave.Legacy/Providers/SQLiteCacheProvider.cs b/src/CacheWeave.Legacy/Providers/SQLiteCacheProvider.cs
new file mode 100644
index 0000000..1490aee
--- /dev/null
+++ b/src/CacheWeave.Legacy/Providers/SQLiteCacheProvider.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Data;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Abstractions;
+using Microsoft.Extensions.Options;
+using System.Data.SQLite;
+
+namespace CacheWeave.Legacy.Providers
+{
+ public sealed class SQLiteCacheProvider : ICacheProviderInner, IDisposable
+ {
+ private readonly SQLiteConnection _connection;
+ private readonly SQLiteCacheOptions _options;
+
+ public SQLiteCacheProvider(IOptions options)
+ {
+ _options = options.Value;
+ _connection = new SQLiteConnection($"Data Source={_options.DatabasePath};Version=3;");
+ _connection.Open();
+ EnsureTable();
+ }
+
+ private void EnsureTable()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = $@"
+ CREATE TABLE IF NOT EXISTS {_options.TableName} (
+ Key TEXT PRIMARY KEY NOT NULL,
+ Value TEXT NOT NULL,
+ ExpiresAt INTEGER
+ )";
+ cmd.ExecuteNonQuery();
+ }
+
+ public async Task GetAsync(string key, CancellationToken cancellationToken = default)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = $"SELECT Value, ExpiresAt FROM {_options.TableName} WHERE Key = $key";
+ cmd.Parameters.AddWithValue("$key", key);
+
+ using var reader = await Task.Run(() => cmd.ExecuteReader(), cancellationToken).ConfigureAwait(false);
+ if (!reader.Read())
+ return null;
+
+ var value = reader.GetString(0);
+ if (!reader.IsDBNull(1))
+ {
+ var expiresAt = reader.GetInt64(1);
+ if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expiresAt)
+ {
+ await RemoveAsync(key, cancellationToken).ConfigureAwait(false);
+ return null;
+ }
+ }
+
+ return value;
+ }
+
+ public async Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = $@"
+ INSERT INTO {_options.TableName} (Key, Value, ExpiresAt)
+ VALUES ($key, $value, $expiresAt)
+ ON CONFLICT(Key) DO UPDATE SET Value = $value, ExpiresAt = $expiresAt";
+ cmd.Parameters.AddWithValue("$key", key);
+ cmd.Parameters.AddWithValue("$value", value);
+ cmd.Parameters.AddWithValue("$expiresAt", expiry.HasValue
+ ? (object)DateTimeOffset.UtcNow.Add(expiry.Value).ToUnixTimeSeconds()
+ : DBNull.Value);
+
+ await Task.Run(() => cmd.ExecuteNonQuery(), cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = $"DELETE FROM {_options.TableName} WHERE Key = $key";
+ cmd.Parameters.AddWithValue("$key", key);
+ await Task.Run(() => cmd.ExecuteNonQuery(), cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
+ {
+ using var cmd = _connection.CreateCommand();
+ // Escape LIKE special characters in the prefix before appending the wildcard
+ var escapedPrefix = prefix.Replace("%", "\\%").Replace("_", "\\_");
+ cmd.CommandText = $"DELETE FROM {_options.TableName} WHERE Key LIKE $prefix ESCAPE '\\'";
+ cmd.Parameters.AddWithValue("$prefix", escapedPrefix + "%");
+ await Task.Run(() => cmd.ExecuteNonQuery(), cancellationToken).ConfigureAwait(false);
+ }
+
+ public void Dispose() => _connection.Dispose();
+ }
+}
diff --git a/src/CacheWeave.Legacy/Serialization/NewtonsoftJsonCacheSerializer.cs b/src/CacheWeave.Legacy/Serialization/NewtonsoftJsonCacheSerializer.cs
new file mode 100644
index 0000000..e8275cd
--- /dev/null
+++ b/src/CacheWeave.Legacy/Serialization/NewtonsoftJsonCacheSerializer.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using CacheWeave.Legacy.Abstractions;
+using Newtonsoft.Json;
+
+namespace CacheWeave.Legacy.Serialization
+{
+ public sealed class NewtonsoftJsonCacheSerializer : ICacheSerializer
+ {
+ private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
+ {
+ NullValueHandling = NullValueHandling.Ignore,
+ DateParseHandling = DateParseHandling.DateTimeOffset,
+ Formatting = Formatting.None,
+ ReferenceLoopHandling = ReferenceLoopHandling.Ignore
+ };
+
+ public string Serialize(T value) =>
+ JsonConvert.SerializeObject(value, Settings);
+
+ [return: MaybeNull]
+ public T Deserialize(string value) =>
+ JsonConvert.DeserializeObject(value, Settings);
+
+ public string Serialize(object value, Type type) =>
+ JsonConvert.SerializeObject(value, type, Settings);
+
+ public object? Deserialize(string value, Type type) =>
+ JsonConvert.DeserializeObject(value, type, Settings);
+ }
+}
diff --git a/src/CacheWeave.Legacy/Serialization/SystemTextJsonCacheSerializer.cs b/src/CacheWeave.Legacy/Serialization/SystemTextJsonCacheSerializer.cs
new file mode 100644
index 0000000..41ba91b
--- /dev/null
+++ b/src/CacheWeave.Legacy/Serialization/SystemTextJsonCacheSerializer.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using CacheWeave.Legacy.Abstractions;
+
+namespace CacheWeave.Legacy.Serialization
+{
+ public sealed class SystemTextJsonCacheSerializer : ICacheSerializer
+ {
+ private static readonly JsonSerializerOptions Options = new JsonSerializerOptions
+ {
+ WriteIndented = false,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ public string Serialize(T value) =>
+ JsonSerializer.Serialize(value, Options);
+
+ [return: MaybeNull]
+ public T Deserialize(string value) =>
+ JsonSerializer.Deserialize(value, Options);
+
+ public string Serialize(object value, Type type) =>
+ JsonSerializer.Serialize(value, type, Options);
+
+ public object? Deserialize(string value, Type type) =>
+ JsonSerializer.Deserialize(value, type, Options);
+ }
+}
diff --git a/src/CacheWeave.Legacy/Services/CacheWeaveService.cs b/src/CacheWeave.Legacy/Services/CacheWeaveService.cs
new file mode 100644
index 0000000..fdfe2ee
--- /dev/null
+++ b/src/CacheWeave.Legacy/Services/CacheWeaveService.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Abstractions;
+using Microsoft.Extensions.Options;
+
+namespace CacheWeave.Legacy.Services
+{
+ public sealed class CacheWeaveService : ICacheWeaveService
+ {
+ private readonly ICacheProvider _provider;
+ private readonly ICacheSerializer _serializer;
+ private readonly ICacheStampedeProtector _stampedeProtector;
+ private readonly CacheWeaveOptions _options;
+
+ public CacheWeaveService(
+ ICacheProvider provider,
+ ICacheSerializer serializer,
+ ICacheStampedeProtector stampedeProtector,
+ IOptions options)
+ {
+ _provider = provider;
+ _serializer = serializer;
+ _stampedeProtector = stampedeProtector;
+ _options = options.Value;
+ }
+
+ [return: MaybeNull]
+ public async Task GetOrSetAsync(
+ string key,
+ Func> factory,
+ TimeSpan? expiry = null,
+ CancellationToken cancellationToken = default)
+ {
+ var prefixedKey = PrefixKey(key);
+
+ var cached = await _provider.GetAsync(prefixedKey, cancellationToken).ConfigureAwait(false);
+ if (cached != null)
+#pragma warning disable CS8603
+ return _serializer.Deserialize(cached);
+#pragma warning restore CS8603
+
+#pragma warning disable CS8602
+ return await _stampedeProtector.ExecuteAsync(prefixedKey, async ct =>
+ {
+#pragma warning restore CS8602
+ // Double-checked inside the lock
+ var recheck = await _provider.GetAsync(prefixedKey, ct).ConfigureAwait(false);
+ if (recheck != null)
+#pragma warning disable CS8603
+ return _serializer.Deserialize(recheck);
+#pragma warning restore CS8603
+
+ var result = await factory(ct).ConfigureAwait(false);
+ if (result != null)
+ {
+ var serialized = _serializer.Serialize(result);
+ var resolvedExpiry = expiry ?? _options.DefaultExpiry;
+ await _provider.SetAsync(prefixedKey, serialized, resolvedExpiry, ct).ConfigureAwait(false);
+ }
+ return result;
+ }, cancellationToken).ConfigureAwait(false);
+ }
+
+ [return: MaybeNull]
+ public async Task GetAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var prefixedKey = PrefixKey(key);
+ var cached = await _provider.GetAsync(prefixedKey, cancellationToken).ConfigureAwait(false);
+#pragma warning disable CS8603
+ return cached is null ? default : _serializer.Deserialize(cached);
+#pragma warning restore CS8603
+ }
+
+ public async Task SetAsync(string key, T value, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
+ {
+ var prefixedKey = PrefixKey(key);
+ var serialized = _serializer.Serialize(value);
+ var resolvedExpiry = expiry ?? _options.DefaultExpiry;
+ await _provider.SetAsync(prefixedKey, serialized, resolvedExpiry, cancellationToken).ConfigureAwait(false);
+ }
+
+ public Task InvalidateAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var prefixedKey = PrefixKey(key);
+ return _provider.RemoveAsync(prefixedKey, cancellationToken);
+ }
+
+ public Task InvalidateByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
+ {
+ var prefixedPrefix = PrefixKey(prefix);
+ return _provider.RemoveByPrefixAsync(prefixedPrefix, cancellationToken);
+ }
+
+ public async Task InvalidateByPrefixesAsync(IEnumerable prefixes, CancellationToken cancellationToken = default)
+ {
+ foreach (var prefix in prefixes)
+ await InvalidateByPrefixAsync(prefix, cancellationToken).ConfigureAwait(false);
+ }
+
+ private string PrefixKey(string key)
+ {
+ if (string.IsNullOrEmpty(_options.GlobalKeyPrefix))
+ return key;
+
+ var sep = _options.KeySeparator;
+ var prefix = _options.GlobalKeyPrefix + sep;
+ return key.StartsWith(prefix, StringComparison.Ordinal) ? key : prefix + key;
+ }
+ }
+}
diff --git a/src/CacheWeave.Legacy/Services/InProcessStampedeProtector.cs b/src/CacheWeave.Legacy/Services/InProcessStampedeProtector.cs
new file mode 100644
index 0000000..992846b
--- /dev/null
+++ b/src/CacheWeave.Legacy/Services/InProcessStampedeProtector.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Abstractions;
+
+namespace CacheWeave.Legacy.Services
+{
+ public sealed class InProcessStampedeProtector : ICacheStampedeProtector
+ {
+ private readonly ConcurrentDictionary _locks =
+ new ConcurrentDictionary();
+
+ [return: MaybeNull]
+ public async Task ExecuteAsync(
+ string key,
+ Func> factory,
+ CancellationToken cancellationToken = default)
+ {
+ var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
+ await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ return await factory(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ semaphore.Release();
+ _locks.TryRemove(key, out _);
+ }
+ }
+ }
+}
diff --git a/tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj b/tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj
new file mode 100644
index 0000000..5cf111d
--- /dev/null
+++ b/tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net48
+ 8.0
+ enable
+ CS8602;CS8603;CS8604
+ false
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
diff --git a/tests/CacheWeave.Legacy.Tests/Compression/CompressingCacheProviderTests.cs b/tests/CacheWeave.Legacy.Tests/Compression/CompressingCacheProviderTests.cs
new file mode 100644
index 0000000..e2a4f39
--- /dev/null
+++ b/tests/CacheWeave.Legacy.Tests/Compression/CompressingCacheProviderTests.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Abstractions;
+using CacheWeave.Legacy.Compression;
+using FluentAssertions;
+using Moq;
+using Xunit;
+
+namespace CacheWeave.Legacy.Tests.Compression
+{
+ public class CompressingCacheProviderTests
+ {
+ private readonly Mock _inner = new Mock();
+ private readonly GZipCacheCompressor _compressor = new GZipCacheCompressor();
+
+ private CompressingCacheProvider MakeSut() =>
+ new CompressingCacheProvider(_inner.Object, _compressor);
+
+ [Fact]
+ public async Task SetAsync_CompressesValueBeforeStoringInInner()
+ {
+ string? stored = null;
+ _inner.Setup(p => p.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((_, v, __, ___) => stored = v)
+ .Returns(Task.CompletedTask);
+
+ var sut = MakeSut();
+ await sut.SetAsync("k", "hello");
+
+ stored.Should().NotBe("hello");
+ _compressor.Decompress(stored!).Should().Be("hello");
+ }
+
+ [Fact]
+ public async Task GetAsync_DecompressesValueFromInner()
+ {
+ var compressed = _compressor.Compress("world");
+ _inner.Setup(p => p.GetAsync("k", It.IsAny()))
+ .ReturnsAsync(compressed);
+
+ var sut = MakeSut();
+ var result = await sut.GetAsync("k");
+
+ result.Should().Be("world");
+ }
+
+ [Fact]
+ public async Task GetAsync_ReturnsNull_WhenInnerReturnsNull()
+ {
+ _inner.Setup(p => p.GetAsync("k", It.IsAny()))
+ .ReturnsAsync((string?)null);
+
+ var sut = MakeSut();
+ var result = await sut.GetAsync("k");
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task RemoveAsync_DelegatesToInner()
+ {
+ _inner.Setup(p => p.RemoveAsync("k", It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var sut = MakeSut();
+ await sut.RemoveAsync("k");
+
+ _inner.Verify(p => p.RemoveAsync("k", It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task RemoveByPrefixAsync_DelegatesToInner()
+ {
+ _inner.Setup(p => p.RemoveByPrefixAsync("prefix:", It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var sut = MakeSut();
+ await sut.RemoveByPrefixAsync("prefix:");
+
+ _inner.Verify(p => p.RemoveByPrefixAsync("prefix:", It.IsAny()), Times.Once);
+ }
+ }
+}
diff --git a/tests/CacheWeave.Legacy.Tests/Compression/GZipCacheCompressorTests.cs b/tests/CacheWeave.Legacy.Tests/Compression/GZipCacheCompressorTests.cs
new file mode 100644
index 0000000..f2cb87d
--- /dev/null
+++ b/tests/CacheWeave.Legacy.Tests/Compression/GZipCacheCompressorTests.cs
@@ -0,0 +1,63 @@
+using System;
+using CacheWeave.Legacy.Compression;
+using FluentAssertions;
+using Xunit;
+
+namespace CacheWeave.Legacy.Tests.Compression
+{
+ public class GZipCacheCompressorTests
+ {
+ private readonly GZipCacheCompressor _sut = new GZipCacheCompressor();
+
+ [Fact]
+ public void Compress_ThenDecompress_ReturnsOriginalValue()
+ {
+ const string original = "Hello, CacheWeave Legacy!";
+ var compressed = _sut.Compress(original);
+ var decompressed = _sut.Decompress(compressed);
+ decompressed.Should().Be(original);
+ }
+
+ [Fact]
+ public void Compress_ProducesBase64String()
+ {
+ var compressed = _sut.Compress("test");
+ var bytes = Convert.FromBase64String(compressed); // should not throw
+ bytes.Should().NotBeEmpty();
+ }
+
+ [Fact]
+ public void Compress_LargePayload_RoundTrips()
+ {
+ var large = new string('x', 100_000);
+ var compressed = _sut.Compress(large);
+ var decompressed = _sut.Decompress(compressed);
+ decompressed.Should().Be(large);
+ }
+
+ [Fact]
+ public void Compress_EmptyString_RoundTrips()
+ {
+ var compressed = _sut.Compress(string.Empty);
+ var decompressed = _sut.Decompress(compressed);
+ decompressed.Should().Be(string.Empty);
+ }
+
+ [Fact]
+ public void Compress_JsonPayload_RoundTrips()
+ {
+ const string json = "{\"id\":1,\"name\":\"product\",\"price\":9.99}";
+ var compressed = _sut.Compress(json);
+ var decompressed = _sut.Decompress(compressed);
+ decompressed.Should().Be(json);
+ }
+
+ [Fact]
+ public void Compress_OutputIsSmallerThanInput_ForRepetitiveContent()
+ {
+ var repetitive = new string('a', 10_000);
+ var compressed = _sut.Compress(repetitive);
+ compressed.Length.Should().BeLessThan(repetitive.Length);
+ }
+ }
+}
diff --git a/tests/CacheWeave.Legacy.Tests/Providers/DisabledCacheProviderTests.cs b/tests/CacheWeave.Legacy.Tests/Providers/DisabledCacheProviderTests.cs
new file mode 100644
index 0000000..dcf9dfc
--- /dev/null
+++ b/tests/CacheWeave.Legacy.Tests/Providers/DisabledCacheProviderTests.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Providers;
+using FluentAssertions;
+using Xunit;
+
+namespace CacheWeave.Legacy.Tests.Providers
+{
+ public class DisabledCacheProviderTests
+ {
+ private readonly DisabledCacheProvider _sut = DisabledCacheProvider.Instance;
+
+ [Fact]
+ public async Task GetAsync_AlwaysReturnsNull()
+ {
+ var result = await _sut.GetAsync("any-key");
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task SetAsync_DoesNotThrow()
+ {
+ Func act = () => _sut.SetAsync("k", "v", TimeSpan.FromMinutes(1));
+ await act.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public async Task RemoveAsync_DoesNotThrow()
+ {
+ Func act = () => _sut.RemoveAsync("k");
+ await act.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public async Task RemoveByPrefixAsync_DoesNotThrow()
+ {
+ Func act = () => _sut.RemoveByPrefixAsync("prefix:");
+ await act.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public async Task SetThenGet_ReturnsNull_BecauseCachingIsDisabled()
+ {
+ await _sut.SetAsync("k", "v");
+ var result = await _sut.GetAsync("k");
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public void Instance_IsSingleton()
+ {
+ DisabledCacheProvider.Instance.Should().BeSameAs(DisabledCacheProvider.Instance);
+ }
+ }
+}
diff --git a/tests/CacheWeave.Legacy.Tests/Providers/InMemoryCacheProviderTests.cs b/tests/CacheWeave.Legacy.Tests/Providers/InMemoryCacheProviderTests.cs
new file mode 100644
index 0000000..81505e8
--- /dev/null
+++ b/tests/CacheWeave.Legacy.Tests/Providers/InMemoryCacheProviderTests.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Providers;
+using FluentAssertions;
+using Xunit;
+
+namespace CacheWeave.Legacy.Tests.Providers
+{
+ public class InMemoryCacheProviderTests : IDisposable
+ {
+ private readonly InMemoryCacheProvider _sut = new InMemoryCacheProvider();
+
+ public void Dispose() => _sut.Dispose();
+
+ // -------------------------------------------------------------------------
+ // Get / Set
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task GetAsync_ReturnsNull_WhenKeyNotPresent()
+ {
+ var result = await _sut.GetAsync("missing");
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task SetAsync_ThenGetAsync_ReturnsValue()
+ {
+ await _sut.SetAsync("k", "v");
+ var result = await _sut.GetAsync("k");
+ result.Should().Be("v");
+ }
+
+ [Fact]
+ public async Task SetAsync_WithExpiry_StoresValue()
+ {
+ await _sut.SetAsync("k", "v", TimeSpan.FromMinutes(5));
+ var result = await _sut.GetAsync("k");
+ result.Should().Be("v");
+ }
+
+ [Fact]
+ public async Task SetAsync_WithNullExpiry_StoresValue()
+ {
+ await _sut.SetAsync("k", "v", expiry: null);
+ var result = await _sut.GetAsync("k");
+ result.Should().Be("v");
+ }
+
+ [Fact]
+ public async Task SetAsync_OverwritesExistingValue()
+ {
+ await _sut.SetAsync("k", "first");
+ await _sut.SetAsync("k", "second");
+ var result = await _sut.GetAsync("k");
+ result.Should().Be("second");
+ }
+
+ // -------------------------------------------------------------------------
+ // Remove
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task RemoveAsync_RemovesExistingKey()
+ {
+ await _sut.SetAsync("k", "v");
+ await _sut.RemoveAsync("k");
+ var result = await _sut.GetAsync("k");
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task RemoveAsync_DoesNotThrow_WhenKeyMissing()
+ {
+ Func act = () => _sut.RemoveAsync("nonexistent");
+ await act.Should().NotThrowAsync();
+ }
+
+ // -------------------------------------------------------------------------
+ // RemoveByPrefix — not supported
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task RemoveByPrefixAsync_ThrowsNotSupportedException()
+ {
+ Func act = () => _sut.RemoveByPrefixAsync("prefix:");
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task RemoveByPrefixAsync_ExceptionMessage_MentionsInMemory()
+ {
+ var ex = await Assert.ThrowsAsync(
+ () => _sut.RemoveByPrefixAsync("prefix:"));
+ ex.Message.Should().Contain("InMemory");
+ }
+
+ // -------------------------------------------------------------------------
+ // CancellationToken is accepted
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task GetAsync_AcceptsCancellationToken()
+ {
+ using var cts = new CancellationTokenSource();
+ var result = await _sut.GetAsync("k", cts.Token);
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task SetAsync_AcceptsCancellationToken()
+ {
+ using var cts = new CancellationTokenSource();
+ Func act = () => _sut.SetAsync("k", "v", null, cts.Token);
+ await act.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public async Task RemoveAsync_AcceptsCancellationToken()
+ {
+ using var cts = new CancellationTokenSource();
+ Func act = () => _sut.RemoveAsync("k", cts.Token);
+ await act.Should().NotThrowAsync();
+ }
+ }
+}
diff --git a/tests/CacheWeave.Legacy.Tests/Serialization/NewtonsoftJsonCacheSerializerTests.cs b/tests/CacheWeave.Legacy.Tests/Serialization/NewtonsoftJsonCacheSerializerTests.cs
new file mode 100644
index 0000000..f5868c9
--- /dev/null
+++ b/tests/CacheWeave.Legacy.Tests/Serialization/NewtonsoftJsonCacheSerializerTests.cs
@@ -0,0 +1,67 @@
+using System;
+using CacheWeave.Legacy.Serialization;
+using FluentAssertions;
+using Xunit;
+
+namespace CacheWeave.Legacy.Tests.Serialization
+{
+ public class NewtonsoftJsonCacheSerializerTests
+ {
+ private readonly NewtonsoftJsonCacheSerializer _sut = new NewtonsoftJsonCacheSerializer();
+
+ private class Item
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ }
+
+ [Fact]
+ public void Serialize_Generic_ThenDeserialize_ReturnsEquivalentObject()
+ {
+ var item = new Item { Id = 1, Name = "Widget" };
+ var json = _sut.Serialize(item);
+ var result = _sut.Deserialize- (json);
+ result.Should().BeEquivalentTo(item);
+ }
+
+ [Fact]
+ public void Serialize_WithType_ThenDeserialize_ReturnsEquivalentObject()
+ {
+ var item = new Item { Id = 2, Name = "Gadget" };
+ var json = _sut.Serialize(item, typeof(Item));
+ var result = _sut.Deserialize(json, typeof(Item)) as Item;
+ result.Should().BeEquivalentTo(item);
+ }
+
+ [Fact]
+ public void Serialize_ProducesValidJson()
+ {
+ var json = _sut.Serialize(new Item { Id = 3, Name = "Thing" });
+ // Newtonsoft preserves PascalCase by default
+ json.Should().ContainAny("\"Id\"", "\"id\"");
+ }
+
+ [Fact]
+ public void Deserialize_ReturnsDefault_ForNullJson()
+ {
+ var result = _sut.Deserialize
- ("null");
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public void Serialize_String_RoundTrips()
+ {
+ var json = _sut.Serialize("hello");
+ var result = _sut.Deserialize(json);
+ result.Should().Be("hello");
+ }
+
+ [Fact]
+ public void Serialize_Int_RoundTrips()
+ {
+ var json = _sut.Serialize(42);
+ var result = _sut.Deserialize(json);
+ result.Should().Be(42);
+ }
+ }
+}
diff --git a/tests/CacheWeave.Legacy.Tests/Serialization/SystemTextJsonCacheSerializerTests.cs b/tests/CacheWeave.Legacy.Tests/Serialization/SystemTextJsonCacheSerializerTests.cs
new file mode 100644
index 0000000..84fc44a
--- /dev/null
+++ b/tests/CacheWeave.Legacy.Tests/Serialization/SystemTextJsonCacheSerializerTests.cs
@@ -0,0 +1,66 @@
+using System;
+using CacheWeave.Legacy.Serialization;
+using FluentAssertions;
+using Xunit;
+
+namespace CacheWeave.Legacy.Tests.Serialization
+{
+ public class SystemTextJsonCacheSerializerTests
+ {
+ private readonly SystemTextJsonCacheSerializer _sut = new SystemTextJsonCacheSerializer();
+
+ private class Item
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ }
+
+ [Fact]
+ public void Serialize_Generic_ThenDeserialize_ReturnsEquivalentObject()
+ {
+ var item = new Item { Id = 1, Name = "Widget" };
+ var json = _sut.Serialize(item);
+ var result = _sut.Deserialize
- (json);
+ result.Should().BeEquivalentTo(item);
+ }
+
+ [Fact]
+ public void Serialize_WithType_ThenDeserialize_ReturnsEquivalentObject()
+ {
+ var item = new Item { Id = 2, Name = "Gadget" };
+ var json = _sut.Serialize(item, typeof(Item));
+ var result = _sut.Deserialize(json, typeof(Item)) as Item;
+ result.Should().BeEquivalentTo(item);
+ }
+
+ [Fact]
+ public void Serialize_UsesCamelCase()
+ {
+ var json = _sut.Serialize(new Item { Id = 3, Name = "Thing" });
+ json.Should().Contain("\"id\"").And.Contain("\"name\"");
+ }
+
+ [Fact]
+ public void Deserialize_ReturnsDefault_ForNullJson()
+ {
+ var result = _sut.Deserialize
- ("null");
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public void Serialize_String_RoundTrips()
+ {
+ var json = _sut.Serialize("hello");
+ var result = _sut.Deserialize(json);
+ result.Should().Be("hello");
+ }
+
+ [Fact]
+ public void Serialize_Int_RoundTrips()
+ {
+ var json = _sut.Serialize(42);
+ var result = _sut.Deserialize(json);
+ result.Should().Be(42);
+ }
+ }
+}
diff --git a/tests/CacheWeave.Legacy.Tests/Services/CacheWeaveServiceTests.cs b/tests/CacheWeave.Legacy.Tests/Services/CacheWeaveServiceTests.cs
new file mode 100644
index 0000000..71b2857
--- /dev/null
+++ b/tests/CacheWeave.Legacy.Tests/Services/CacheWeaveServiceTests.cs
@@ -0,0 +1,203 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Abstractions;
+using CacheWeave.Legacy.Serialization;
+using CacheWeave.Legacy.Services;
+using FluentAssertions;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace CacheWeave.Legacy.Tests.Services
+{
+ public class CacheWeaveServiceTests
+ {
+ private readonly Mock _provider = new Mock();
+ private readonly SystemTextJsonCacheSerializer _serializer = new SystemTextJsonCacheSerializer();
+ private readonly InProcessStampedeProtector _stampede = new InProcessStampedeProtector();
+ private readonly CacheWeaveOptions _opts = new CacheWeaveOptions { DefaultExpiry = TimeSpan.FromMinutes(5) };
+
+ private CacheWeaveService MakeSut() => new CacheWeaveService(
+ _provider.Object,
+ _serializer,
+ _stampede,
+ Options.Create(_opts));
+
+ private class Item
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ }
+
+ // -------------------------------------------------------------------------
+ // GetOrSetAsync
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task GetOrSetAsync_ReturnsCachedValue_OnHit()
+ {
+ var item = new Item { Id = 1, Name = "Steel" };
+ _provider.Setup(p => p.GetAsync("k", default))
+ .ReturnsAsync(_serializer.Serialize(item));
+
+ var sut = MakeSut();
+ var result = await sut.GetOrSetAsync
- ("k", _ => Task.FromResult
- (new Item { Id = 99, Name = "Other" }));
+
+ result.Should().BeEquivalentTo(item);
+ _provider.Verify(p => p.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), default), Times.Never);
+ }
+
+ [Fact]
+ public async Task GetOrSetAsync_InvokesFactory_OnMiss_AndStores()
+ {
+ _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync((string?)null);
+
+ var sut = MakeSut();
+ var result = await sut.GetOrSetAsync
- ("k", _ => Task.FromResult
- (new Item { Id = 1, Name = "Steel" }));
+
+ result.Should().BeEquivalentTo(new Item { Id = 1, Name = "Steel" });
+ _provider.Verify(p => p.SetAsync("k", It.IsAny(), _opts.DefaultExpiry, default), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetOrSetAsync_UsesExplicitExpiry_WhenProvided()
+ {
+ _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync((string?)null);
+ var sut = MakeSut();
+
+ await sut.GetOrSetAsync
- ("k", _ => Task.FromResult
- (new Item { Id = 1, Name = "X" }), TimeSpan.FromSeconds(30));
+
+ _provider.Verify(p => p.SetAsync("k", It.IsAny(), TimeSpan.FromSeconds(30), default), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetOrSetAsync_DoesNotStore_WhenFactoryReturnsNull()
+ {
+ _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync((string?)null);
+ var sut = MakeSut();
+
+ var result = await sut.GetOrSetAsync
- ("k", _ => Task.FromResult
- (null!));
+
+ result.Should().BeNull();
+ _provider.Verify(p => p.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), default), Times.Never);
+ }
+
+ // -------------------------------------------------------------------------
+ // GetAsync
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task GetAsync_ReturnsDeserializedValue_OnHit()
+ {
+ var item = new Item { Id = 5, Name = "Copper" };
+ _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync(_serializer.Serialize(item));
+ var sut = MakeSut();
+
+ var result = await sut.GetAsync
- ("k");
+
+ result.Should().BeEquivalentTo(item);
+ }
+
+ [Fact]
+ public async Task GetAsync_ReturnsDefault_OnMiss()
+ {
+ _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync((string?)null);
+ var sut = MakeSut();
+
+ var result = await sut.GetAsync
- ("k");
+
+ result.Should().BeNull();
+ }
+
+ // -------------------------------------------------------------------------
+ // SetAsync
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task SetAsync_SerializesAndStores()
+ {
+ var sut = MakeSut();
+ var item = new Item { Id = 3, Name = "Iron" };
+
+ await sut.SetAsync("k", item);
+
+ _provider.Verify(p => p.SetAsync("k", It.IsAny(), _opts.DefaultExpiry, default), Times.Once);
+ }
+
+ [Fact]
+ public async Task SetAsync_UsesExplicitExpiry()
+ {
+ var sut = MakeSut();
+
+ await sut.SetAsync("k", new Item { Id = 1, Name = "X" }, TimeSpan.FromSeconds(10));
+
+ _provider.Verify(p => p.SetAsync("k", It.IsAny(), TimeSpan.FromSeconds(10), default), Times.Once);
+ }
+
+ // -------------------------------------------------------------------------
+ // InvalidateAsync / InvalidateByPrefixAsync
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task InvalidateAsync_CallsRemove()
+ {
+ var sut = MakeSut();
+ await sut.InvalidateAsync("k");
+ _provider.Verify(p => p.RemoveAsync("k", default), Times.Once);
+ }
+
+ [Fact]
+ public async Task InvalidateByPrefixAsync_CallsRemoveByPrefix()
+ {
+ var sut = MakeSut();
+ await sut.InvalidateByPrefixAsync("prefix:");
+ _provider.Verify(p => p.RemoveByPrefixAsync("prefix:", default), Times.Once);
+ }
+
+ [Fact]
+ public async Task InvalidateByPrefixesAsync_CallsRemoveByPrefix_ForEachPrefix()
+ {
+ var sut = MakeSut();
+ await sut.InvalidateByPrefixesAsync(new[] { "products:", "dashboard:", "stats:" });
+
+ _provider.Verify(p => p.RemoveByPrefixAsync("products:", default), Times.Once);
+ _provider.Verify(p => p.RemoveByPrefixAsync("dashboard:", default), Times.Once);
+ _provider.Verify(p => p.RemoveByPrefixAsync("stats:", default), Times.Once);
+ }
+
+ [Fact]
+ public async Task InvalidateByPrefixesAsync_EmptyList_DoesNotCallProvider()
+ {
+ var sut = MakeSut();
+ await sut.InvalidateByPrefixesAsync(new string[0]);
+ _provider.Verify(p => p.RemoveByPrefixAsync(It.IsAny(), default), Times.Never);
+ }
+
+ // -------------------------------------------------------------------------
+ // GlobalKeyPrefix
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task SetAsync_PrependGlobalKeyPrefix_WhenConfigured()
+ {
+ _opts.GlobalKeyPrefix = "myapp";
+ var sut = MakeSut();
+
+ await sut.SetAsync("products", "value");
+
+ _provider.Verify(p => p.SetAsync("myapp:products", It.IsAny(), It.IsAny(), default), Times.Once);
+ }
+
+ [Fact]
+ public async Task SetAsync_DoesNotDoublePrefix_WhenKeyAlreadyPrefixed()
+ {
+ _opts.GlobalKeyPrefix = "myapp";
+ var sut = MakeSut();
+
+ await sut.SetAsync("myapp:products", "value");
+
+ _provider.Verify(p => p.SetAsync("myapp:products", It.IsAny(), It.IsAny(), default), Times.Once);
+ }
+ }
+}
diff --git a/tests/CacheWeave.Legacy.Tests/Services/InProcessStampedeProtectorTests.cs b/tests/CacheWeave.Legacy.Tests/Services/InProcessStampedeProtectorTests.cs
new file mode 100644
index 0000000..6a897fd
--- /dev/null
+++ b/tests/CacheWeave.Legacy.Tests/Services/InProcessStampedeProtectorTests.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using CacheWeave.Legacy.Services;
+using FluentAssertions;
+using Xunit;
+
+namespace CacheWeave.Legacy.Tests.Services
+{
+ public class InProcessStampedeProtectorTests
+ {
+ private readonly InProcessStampedeProtector _sut = new InProcessStampedeProtector();
+
+ [Fact]
+ public async Task ExecuteAsync_ReturnsFactoryResult()
+ {
+ var result = await _sut.ExecuteAsync("k", _ => Task.FromResult("value"));
+ result.Should().Be("value");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ReturnsNull_WhenFactoryReturnsNull()
+ {
+ var result = await _sut.ExecuteAsync("k", _ => Task.FromResult(null!));
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SerializesCallsForSameKey()
+ {
+ var callOrder = new List();
+ var tcs = new TaskCompletionSource();
+
+ var t1 = _sut.ExecuteAsync("k", async _ =>
+ {
+ callOrder.Add(1);
+ await tcs.Task;
+ return 1;
+ });
+
+ // Give t1 time to acquire the lock
+ await Task.Delay(50);
+
+ var t2 = _sut.ExecuteAsync("k", _ =>
+ {
+ callOrder.Add(2);
+ return Task.FromResult(2);
+ });
+
+ tcs.SetResult(true);
+ await Task.WhenAll(t1, t2);
+
+ // t1 must complete before t2 starts
+ callOrder[0].Should().Be(1);
+ callOrder[1].Should().Be(2);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_AllowsConcurrentCallsForDifferentKeys()
+ {
+ var barrier = new SemaphoreSlim(0, 2);
+ var reached = 0;
+
+ var t1 = _sut.ExecuteAsync("key1", async _ =>
+ {
+ Interlocked.Increment(ref reached);
+ barrier.Release();
+ await Task.Delay(50);
+ return 1;
+ });
+
+ var t2 = _sut.ExecuteAsync("key2", async _ =>
+ {
+ Interlocked.Increment(ref reached);
+ barrier.Release();
+ await Task.Delay(50);
+ return 2;
+ });
+
+ // Both should reach the barrier concurrently
+ var bothReached = await barrier.WaitAsync(TimeSpan.FromSeconds(2)) &&
+ await barrier.WaitAsync(TimeSpan.FromSeconds(2));
+
+ bothReached.Should().BeTrue();
+ await Task.WhenAll(t1, t2);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_PropagatesException_FromFactory()
+ {
+ Func act = () => _sut.ExecuteAsync("k", _ => throw new InvalidOperationException("boom"));
+ await act.Should().ThrowAsync().WithMessage("boom");
+ }
+ }
+}