diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs
new file mode 100644
index 0000000000..9b205de1a9
--- /dev/null
+++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs
@@ -0,0 +1,81 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) Marimer LLC. All rights reserved.
+// Website: https://cslanet.com
+//
+// Configuration extensions for the Azure Table Storage session store
+//-----------------------------------------------------------------------
+
+using Azure.Data.Tables;
+using Csla.Blazor.State.AzureTableStorage;
+using Csla.State;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Csla.Configuration
+{
+ ///
+ /// Implement extension methods to register the Azure Table Storage
+ /// session store with the CSLA configuration system.
+ ///
+ public static class AzureTableStorageConfigurationExtensions
+ {
+ ///
+ /// Configures CSLA to use the Azure Table Storage
+ /// implementation.
+ ///
+ /// The instance.
+ /// An optional delegate to configure the store.
+ /// The instance.
+ ///
+ /// Call this after AddServerSideBlazor, which registers the default
+ /// in-memory session store; the last registration wins. The session store
+ /// is only used when UseInMemoryApplicationContextManager is
+ /// . Call
+ /// periodically: besides removing expired sessions, it refreshes the stored
+ /// last-touched time of live sessions, so other servers do not purge them.
+ ///
+ /// Live sessions are kept in memory. Session data is written to the table
+ /// when a session is saved through ,
+ /// such as when the WebAssembly client sends its state; changes made in
+ /// place are written with the next save. A session can serialize to at
+ /// most 704 KiB.
+ ///
+ ///
+ /// The comes from
+ /// when it is set,
+ /// otherwise from the keyed service named by
+ /// , otherwise from the
+ /// default registered in the container.
+ ///
+ ///
+ /// is .
+ public static CslaOptions UseAzureTableStorageSessionStore(this CslaOptions config, Action? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+
+ var storeOptions = new AzureTableStorageOptions();
+ options?.Invoke(storeOptions);
+
+ // Singletons, because the singleton SessionManager depends on ISessionStore.
+ config.Services.AddSingleton(storeOptions);
+ config.Services.TryAddSingleton(TimeProvider.System);
+
+ // The client is resolved privately rather than registered, so the store
+ // never changes which TableServiceClient the rest of the app receives.
+ config.Services.AddSingleton(sp =>
+ ActivatorUtilities.CreateInstance(
+ sp, GetTableServiceClient(sp, sp.GetRequiredService())));
+ return config;
+ }
+
+ internal static TableServiceClient GetTableServiceClient(IServiceProvider serviceProvider, AzureTableStorageOptions options)
+ {
+ if (!string.IsNullOrEmpty(options.ConnectionString))
+ return new TableServiceClient(options.ConnectionString);
+ if (options.ServiceKey is not null)
+ return serviceProvider.GetRequiredKeyedService(options.ServiceKey);
+ return serviceProvider.GetRequiredService();
+ }
+ }
+}
diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs
new file mode 100644
index 0000000000..041a0b1fdf
--- /dev/null
+++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs
@@ -0,0 +1,52 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) Marimer LLC. All rights reserved.
+// Website: https://cslanet.com
+//
+// Options for the Azure Table Storage session store
+//-----------------------------------------------------------------------
+
+namespace Csla.Blazor.State.AzureTableStorage;
+
+///
+/// Options for configuring the Azure Table Storage
+/// implementation.
+///
+public class AzureTableStorageOptions
+{
+ ///
+ /// Gets or sets the Azure Storage connection string used to
+ /// create the .
+ ///
+ ///
+ /// Leave or empty to use a
+ /// that is already
+ /// registered in the dependency injection container (for example, one
+ /// registered by .NET Aspire or by managed-identity wiring).
+ ///
+ public string? ConnectionString { get; set; }
+
+ ///
+ /// Gets or sets the dependency injection service key used to resolve a
+ /// keyed .
+ ///
+ ///
+ /// Set this when the host registered the client as a keyed service (for
+ /// example builder.AddKeyedAzureTableClient("state")). Ignored when
+ /// is supplied.
+ ///
+ public object? ServiceKey { get; set; }
+
+ ///
+ /// Gets or sets the name of the Azure Storage table used to store
+ /// sessions. Defaults to CslaStateSessionManager.
+ ///
+ public string TableName { get; set; } = "CslaStateSessionManager";
+
+ ///
+ /// Gets or sets a value used to discriminate sessions belonging to this
+ /// application when multiple applications share the same storage table.
+ /// Defaults to an empty string.
+ ///
+ public string ApplicationDiscriminator { get; set; } = string.Empty;
+}
diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs
new file mode 100644
index 0000000000..f4223d5f53
--- /dev/null
+++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs
@@ -0,0 +1,270 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) Marimer LLC. All rights reserved.
+// Website: https://cslanet.com
+//
+// Azure Table Storage implementation of ISessionStore
+//-----------------------------------------------------------------------
+
+using System.Collections.Concurrent;
+using System.Net;
+using Azure;
+using Azure.Data.Tables;
+using Csla.Properties;
+using Csla.Serialization;
+using Csla.State;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Csla.Blazor.State.AzureTableStorage;
+
+///
+/// Azure Table Storage implementation of .
+///
+///
+///
+/// Live sessions are kept in memory and returned by reference, so callers
+/// can change a session in place. The table is read only when a session is
+/// not in memory, such as after a restart.
+///
+///
+/// Session data is written to the table only by
+/// .
+/// refreshes only the stored last-touched time of live sessions, so it never
+/// serializes a session that a request may be changing.
+///
+///
+/// Read and write failures propagate to the caller, so a failed read is never
+/// mistaken for a missing session.
+///
+///
+internal sealed partial class AzureTableStorageSessionStore : ISessionStore, IDisposable
+{
+ // Azure Tables limits a binary property to 64 KiB, so session data is split
+ // across several columns of this size.
+ internal const int ChunkSize = 64 * 1024;
+ // The 1 MiB entity limit can be measured on the request, where binary
+ // values are base64 encoded (Azurite measures it that way), so 11 chunks
+ // (704 KiB) is the most that fits however it is measured.
+ internal const int MaxChunks = 11;
+ private const string ValueColumn = "Value";
+ private const string LastTouchedColumn = nameof(Session.LastTouched);
+
+ private readonly ConcurrentDictionary _sessions = [];
+ private readonly SemaphoreSlim[] _keyLocks = [.. Enumerable.Range(0, 32).Select(_ => new SemaphoreSlim(1, 1))];
+ private readonly string _applicationDiscriminator;
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+ private readonly TableClient _tableClient;
+
+ ///
+ /// Initializes a new instance of the
+ /// class, creating the
+ /// storage table if it does not exist.
+ ///
+ /// The Azure table service client.
+ /// The session store options.
+ /// Creates the scopes the serialization formatter is resolved in.
+ /// The time provider used to find expired sessions.
+ /// The logger.
+ /// When any argument is .
+ public AzureTableStorageSessionStore(
+ TableServiceClient tableServiceClient,
+ AzureTableStorageOptions options,
+ IServiceScopeFactory scopeFactory,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(tableServiceClient);
+ ArgumentNullException.ThrowIfNull(options);
+
+ _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
+ _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _applicationDiscriminator = options.ApplicationDiscriminator;
+
+ _tableClient = tableServiceClient.GetTableClient(options.TableName);
+ _tableClient.CreateIfNotExists();
+ }
+
+ ///
+ public async ValueTask GetSessionAsync(string key)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+
+ if (_sessions.TryGetValue(key, out var session))
+ return session;
+
+ LogLoadingSession(key, _tableClient.Name);
+ var response = await _tableClient.GetEntityIfExistsAsync(key, _applicationDiscriminator).ConfigureAwait(false);
+ if (!response.HasValue || response.Value is null)
+ return null;
+
+ // Callers that load the same key at the same time all get the instance
+ // that is cached first, so they all change the same session in place.
+ var entity = response.Value;
+ return _sessions.GetOrAdd(key, _ => Deserialize(ReadData(entity)));
+ }
+
+ ///
+ /// The session serializes to more than the store can hold.
+ public async ValueTask AddOrUpdateSessionAsync(string key, Session session)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+ ArgumentNullException.ThrowIfNull(session);
+
+ // The lock keeps the table and the cache in the same order for one key.
+ var keyLock = GetKeyLock(key);
+ await keyLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ LogSavingSession(key, _tableClient.Name);
+ await _tableClient.UpsertEntityAsync(CreateEntity(key, session), TableUpdateMode.Replace).ConfigureAwait(false);
+ _sessions[key] = session;
+ }
+ finally
+ {
+ keyLock.Release();
+ }
+ }
+
+ ///
+ public async ValueTask PurgeExpiredSessionsAsync(TimeSpan expiration)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(expiration.Ticks, nameof(expiration));
+
+ var cutoff = _timeProvider.GetUtcNow().ToUnixTimeSeconds() - (long)expiration.TotalSeconds;
+
+ // Purging is best effort: one failure should not stop the rest of the sweep.
+ foreach (var item in _sessions)
+ {
+ if (item.Value.LastTouched < cutoff)
+ {
+ _sessions.TryRemove(item);
+ continue;
+ }
+ try
+ {
+ await RefreshLastTouchedAsync(item.Key).ConfigureAwait(false);
+ }
+ catch (RequestFailedException ex)
+ {
+ LogRefreshFailed(ex, item.Key, _tableClient.Name);
+ }
+ }
+
+ var filter = TableClient.CreateQueryFilter($"RowKey eq {_applicationDiscriminator} and LastTouched lt {cutoff}");
+ var expired = _tableClient.QueryAsync(filter, select: [nameof(TableEntity.PartitionKey), nameof(TableEntity.RowKey)]);
+
+ await foreach (var entity in expired.ConfigureAwait(false))
+ {
+ if (_sessions.ContainsKey(entity.PartitionKey))
+ continue;
+ try
+ {
+ // The ETag makes the delete fail if the session was saved after the query.
+ await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey, entity.ETag).ConfigureAwait(false);
+ }
+ catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.PreconditionFailed)
+ {
+ // Expected: the session was saved after the query, so it is still in use.
+ LogPurgeSkipped(entity.PartitionKey, _tableClient.Name);
+ }
+ catch (RequestFailedException ex)
+ {
+ LogPurgeFailed(ex, entity.PartitionKey, _tableClient.Name);
+ }
+ }
+ }
+
+ ///
+ /// Releases the locks used to coordinate writes.
+ ///
+ public void Dispose()
+ {
+ foreach (var keyLock in _keyLocks)
+ keyLock.Dispose();
+ }
+
+ // Writes only the timestamp, so the stored session data is never replaced
+ // by a copy serialized while a request was changing it.
+ private async Task RefreshLastTouchedAsync(string key)
+ {
+ var keyLock = GetKeyLock(key);
+ await keyLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ // Read the session under the lock, so the timestamp written is that of
+ // the session the last AddOrUpdateSessionAsync stored.
+ if (!_sessions.TryGetValue(key, out var session))
+ return;
+ var entity = new TableEntity(key, _applicationDiscriminator) { [LastTouchedColumn] = session.LastTouched };
+ await _tableClient.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Merge).ConfigureAwait(false);
+ }
+ finally
+ {
+ keyLock.Release();
+ }
+ }
+
+ private SemaphoreSlim GetKeyLock(string key) =>
+ _keyLocks[(uint)StringComparer.Ordinal.GetHashCode(key) % (uint)_keyLocks.Length];
+
+ private TableEntity CreateEntity(string key, Session session)
+ {
+ var data = Serialize(session);
+ if (data.Length > MaxChunks * ChunkSize)
+ throw new InvalidOperationException(
+ string.Format(Resources.SessionStoreSessionTooLarge, key, data.Length, MaxChunks * ChunkSize));
+
+ var entity = new TableEntity(key, _applicationDiscriminator) { [LastTouchedColumn] = session.LastTouched };
+ var chunkCount = Math.Max(1, (data.Length + ChunkSize - 1) / ChunkSize);
+ for (var index = 0; index < chunkCount; index++)
+ {
+ var offset = index * ChunkSize;
+ entity[GetValueColumn(index)] = data.AsSpan(offset, Math.Min(ChunkSize, data.Length - offset)).ToArray();
+ }
+ return entity;
+ }
+
+ private static byte[] ReadData(TableEntity entity)
+ {
+ var chunks = new List();
+ for (var index = 0; index < MaxChunks && entity.GetBinary(GetValueColumn(index)) is { } chunk; index++)
+ chunks.Add(chunk);
+ return chunks.Count == 1 ? chunks[0] : [.. chunks.SelectMany(c => c)];
+ }
+
+ // The first column keeps the name "Value", so single-column rows still read.
+ private static string GetValueColumn(int index) => index == 0 ? ValueColumn : ValueColumn + index;
+
+ // The formatter depends on the scoped ApplicationContext and keeps
+ // per-call state, so each operation resolves its own in a new scope.
+ private byte[] Serialize(Session session)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ return scope.ServiceProvider.GetRequiredService().Serialize(session);
+ }
+
+ private Session Deserialize(byte[] data)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ return (Session)scope.ServiceProvider.GetRequiredService().Deserialize(data)!;
+ }
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Loading session {Key} from table {Table}")]
+ private partial void LogLoadingSession(string key, string table);
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Saving session {Key} to table {Table}")]
+ private partial void LogSavingSession(string key, string table);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to refresh the last-touched time of session {Key} in table {Table}")]
+ private partial void LogRefreshFailed(Exception exception, string key, string table);
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Kept expired session {Key} in table {Table}, because it was saved after the purge query")]
+ private partial void LogPurgeSkipped(string key, string table);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to purge expired session {Key} from table {Table}")]
+ private partial void LogPurgeFailed(Exception exception, string key, string table);
+}
diff --git a/Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj b/Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj
new file mode 100644
index 0000000000..63ddefa581
--- /dev/null
+++ b/Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+ net10.0;net11.0
+ CSLA .NET Azure Table Storage session store
+ Azure Table Storage implementation of the CSLA .NET ISessionStore for Blazor server/WASM state.
+ true
+ ..\..\Bin
+ CSLA .NET Azure Table Storage session store
+ CSLA;Azure;Table Storage;Session;Blazor;State
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Csla/Properties/Resources.Designer.cs b/Source/Csla/Properties/Resources.Designer.cs
index f216c58268..153463ddb5 100644
--- a/Source/Csla/Properties/Resources.Designer.cs
+++ b/Source/Csla/Properties/Resources.Designer.cs
@@ -1086,6 +1086,15 @@ public static string SessionManagerSessionStateCouldNotBeRetrieved {
}
}
+ ///
+ /// Looks up a localized string similar to Session {0} serializes to {1} bytes, more than the {2} bytes the session store can hold..
+ ///
+ public static string SessionStoreSessionTooLarge {
+ get {
+ return ResourceManager.GetString("SessionStoreSessionTooLarge", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to t.
///
diff --git a/Source/Csla/Properties/Resources.resx b/Source/Csla/Properties/Resources.resx
index a4110d513b..fac07193a5 100644
--- a/Source/Csla/Properties/Resources.resx
+++ b/Source/Csla/Properties/Resources.resx
@@ -569,4 +569,8 @@
{0}.Result is not available; it may have been removed by trimming.
+
+ Session {0} serializes to {1} bytes, more than the {2} bytes the session store can hold.
+ 0 - session key, 1 - serialized size, 2 - maximum size
+
\ No newline at end of file
diff --git a/Source/csla.build.sln b/Source/csla.build.sln
index 715961a794..9d4bec918b 100644
--- a/Source/csla.build.sln
+++ b/Source/csla.build.sln
@@ -69,6 +69,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Testing", "Csla.Testin
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Xaml.Avalonia", "Csla.Xaml.Avalonia\Csla.Xaml.Avalonia.csproj", "{F6080E51-675F-47A8-B125-55DCF66359F8}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Blazor.State.AzureTableStorage", "Csla.Blazor.State.AzureTableStorage\Csla.Blazor.State.AzureTableStorage.csproj", "{7BB42513-1D79-474D-B31E-99673AD1BEBF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Ad-Hoc|Any CPU = Ad-Hoc|Any CPU
@@ -3973,6 +3975,206 @@ Global
{F6080E51-675F-47A8-B125-55DCF66359F8}.Testing|x64.Build.0 = Debug|Any CPU
{F6080E51-675F-47A8-B125-55DCF66359F8}.Testing|x86.ActiveCfg = Debug|Any CPU
{F6080E51-675F-47A8-B125-55DCF66359F8}.Testing|x86.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|AnyOS.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|AnyOS.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|ARM.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|ARM.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Mixed Platforms.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win32.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win32.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x86.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x86.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Any CPU.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|AnyOS.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|AnyOS.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|ARM.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|ARM.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhone.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhone.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Mixed Platforms.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win32.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win32.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x86.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x86.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Any CPU.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|AnyOS.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|AnyOS.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|ARM.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|ARM.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhone.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhone.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Mixed Platforms.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win32.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win32.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x86.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x86.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|AnyOS.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|AnyOS.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|ARM.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhone.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhone.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win32.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win32.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x86.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Any CPU.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|AnyOS.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|AnyOS.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|ARM.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|ARM.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhone.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhone.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Mixed Platforms.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win32.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win32.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x86.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x86.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|AnyOS.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|AnyOS.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|ARM.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|ARM.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhone.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhone.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win32.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win32.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x86.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x86.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Any CPU.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|AnyOS.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|AnyOS.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|ARM.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|ARM.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhone.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhone.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Mixed Platforms.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win32.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win32.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x86.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x86.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|AnyOS.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|AnyOS.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|ARM.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|ARM.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhone.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhone.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win32.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win32.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win64.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win64.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x64.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x64.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x86.ActiveCfg = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x86.Build.0 = Release|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Any CPU.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|AnyOS.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|AnyOS.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|ARM.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|ARM.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhone.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhone.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Mixed Platforms.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win32.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win32.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x86.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x86.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Any CPU.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|AnyOS.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|AnyOS.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|ARM.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|ARM.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhone.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhone.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Mixed Platforms.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win32.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win32.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x64.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x64.Build.0 = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x86.ActiveCfg = Debug|Any CPU
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -4000,6 +4202,7 @@ Global
{50A9114F-5D22-43EF-43CF-559234473966} = {580424DE-C982-4E80-A948-EF42FC3AB110}
{5ECC7150-E415-4FD6-99A4-08110DB54EE1} = {85799E16-9B3B-4BD5-98D6-238E31291134}
{F6080E51-675F-47A8-B125-55DCF66359F8} = {A26EBA9F-1777-46F2-994A-4C4D3055873D}
+ {7BB42513-1D79-474D-B31E-99673AD1BEBF} = {A26EBA9F-1777-46F2-994A-4C4D3055873D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A9AB8D62-C92D-4E97-A981-46E0A5AF56D1}
diff --git a/Source/csla.test.sln b/Source/csla.test.sln
index 92623a7e0c..deefcfaffd 100644
--- a/Source/csla.test.sln
+++ b/Source/csla.test.sln
@@ -95,6 +95,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Xaml.Avalonia", "Csla.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Xaml.Wpf.Tests", "tests\Csla.Xaml.Wpf.Tests\Csla.Xaml.Wpf.Tests.csproj", "{48FD4B37-2ED2-4221-8DC5-A358C21A2E11}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Blazor.State.AzureTableStorage", "Csla.Blazor.State.AzureTableStorage\Csla.Blazor.State.AzureTableStorage.csproj", "{87583187-4AF8-4707-9D05-9118DA296FBD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Blazor.State.AzureTableStorage.Tests", "tests\Csla.Blazor.State.AzureTableStorage.Tests\Csla.Blazor.State.AzureTableStorage.Tests.csproj", "{D8D85314-A28F-4916-B3C9-47F030137828}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.AotSmokeTest", "tests\Csla.AotSmokeTest\Csla.AotSmokeTest.csproj", "{3B872CD3-6730-4790-A422-40C5B1D2F4BB}"
EndProject
Global
@@ -1561,6 +1564,86 @@ Global
{48FD4B37-2ED2-4221-8DC5-A358C21A2E11}.Testing|x64.Build.0 = Debug|Any CPU
{48FD4B37-2ED2-4221-8DC5-A358C21A2E11}.Testing|x86.ActiveCfg = Debug|Any CPU
{48FD4B37-2ED2-4221-8DC5-A358C21A2E11}.Testing|x86.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|ARM.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x64.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x86.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|Any CPU.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|ARM.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|ARM.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x64.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x64.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x86.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x86.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|Any CPU.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|Any CPU.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|ARM.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|ARM.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x64.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x64.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x86.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x86.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|ARM.ActiveCfg = Release|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|ARM.Build.0 = Release|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x64.ActiveCfg = Release|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x64.Build.0 = Release|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x86.ActiveCfg = Release|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x86.Build.0 = Release|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|Any CPU.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|Any CPU.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|ARM.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|ARM.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x64.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x64.Build.0 = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x86.ActiveCfg = Debug|Any CPU
+ {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x86.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|ARM.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x64.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x86.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|ARM.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|ARM.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x64.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x64.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x86.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x86.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|Any CPU.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|Any CPU.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|ARM.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|ARM.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x64.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x64.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x86.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x86.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Release|ARM.ActiveCfg = Release|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Release|ARM.Build.0 = Release|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x64.ActiveCfg = Release|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x64.Build.0 = Release|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x86.ActiveCfg = Release|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x86.Build.0 = Release|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|Any CPU.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|Any CPU.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|ARM.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|ARM.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x64.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x64.Build.0 = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x86.ActiveCfg = Debug|Any CPU
+ {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x86.Build.0 = Debug|Any CPU
{3B872CD3-6730-4790-A422-40C5B1D2F4BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B872CD3-6730-4790-A422-40C5B1D2F4BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B872CD3-6730-4790-A422-40C5B1D2F4BB}.Debug|ARM.ActiveCfg = Debug|Any CPU
@@ -1631,6 +1714,7 @@ Global
{50A9114F-5D22-43EF-43CF-559234473966} = {01AA1A57-727E-4665-93FF-6E8BDD316ADE}
{45D65B40-1387-4D6F-AFA7-E8AE40E442FC} = {01AA1A57-727E-4665-93FF-6E8BDD316ADE}
{8276B605-DBCF-484A-9603-BA1A6201A9F3} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {87583187-4AF8-4707-9D05-9118DA296FBD} = {01AA1A57-727E-4665-93FF-6E8BDD316ADE}
{3B872CD3-6730-4790-A422-40C5B1D2F4BB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs
new file mode 100644
index 0000000000..3b3f6e763d
--- /dev/null
+++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs
@@ -0,0 +1,299 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) Marimer LLC. All rights reserved.
+// Website: https://cslanet.com
+//
+// Integration tests for the Azure Table Storage session store (require Azurite)
+//-----------------------------------------------------------------------
+
+using Azure.Data.Tables;
+using FluentAssertions;
+using Csla.Configuration;
+using Csla.State;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Time.Testing;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Csla.Blazor.State.AzureTableStorage.Tests;
+
+///
+/// Round-trip tests against a real Azure Table endpoint. They use the
+/// well-known Azurite development connection string and are excluded from CI
+/// because they require the emulator (or a live storage account) to be running.
+/// Start Azurite locally (for example: azurite) before running them.
+///
+///
+/// A second store instance over the same table stands in for a restarted or
+/// different server: it has no sessions in memory, so it reads the table.
+///
+[TestClass]
+[TestCategory("SkipOnCIServer")]
+public class AzureTableStorageSessionStoreTests
+{
+ private const string DevelopmentConnectionString = "UseDevelopmentStorage=true";
+ private const string Discriminator = "test-app";
+
+ private ServiceProvider _provider = null!;
+ private TableServiceClient _serviceClient = null!;
+ private FakeTimeProvider _timeProvider = null!;
+ private string _tableName = null!;
+
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddCsla();
+ _provider = services.BuildServiceProvider();
+
+ // Session.Touch uses the system clock, so the fake clock starts at the real time.
+ _timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
+
+ // Unique table per test so runs do not collide. Table names must be alphanumeric.
+ _tableName = "CslaTest" + Guid.NewGuid().ToString("N");
+ _serviceClient = new TableServiceClient(DevelopmentConnectionString);
+ }
+
+ [TestCleanup]
+ public void TestCleanup()
+ {
+ _serviceClient.DeleteTable(_tableName);
+ _provider.Dispose();
+ }
+
+ private AzureTableStorageSessionStore CreateStore(string discriminator = Discriminator)
+ {
+ var options = new AzureTableStorageOptions
+ {
+ TableName = _tableName,
+ ApplicationDiscriminator = discriminator,
+ };
+ return new AzureTableStorageSessionStore(
+ _serviceClient,
+ options,
+ _provider.GetRequiredService(),
+ _timeProvider,
+ _provider.GetRequiredService>());
+ }
+
+ private static long OneDayAgo => DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds();
+
+ private async Task AddStaleEntityAsync(string key, string discriminator)
+ {
+ var entity = new TableEntity(key, discriminator)
+ {
+ ["Value"] = new byte[] { 0 },
+ ["LastTouched"] = OneDayAgo,
+ };
+ await _serviceClient.GetTableClient(_tableName).UpsertEntityAsync(entity);
+ }
+
+ private async Task GetEntityAsync(string key, string discriminator = Discriminator)
+ {
+ var response = await _serviceClient.GetTableClient(_tableName).GetEntityIfExistsAsync(key, discriminator);
+ return response.HasValue ? response.Value : null;
+ }
+
+ private async Task EntityExistsAsync(string key, string discriminator = Discriminator) =>
+ await GetEntityAsync(key, discriminator) is not null;
+
+ private static Session CreateSessionOfSize(int approximateBytes) =>
+ new() { ["payload"] = new string('x', approximateBytes) };
+
+ [TestMethod]
+ public async Task GetSessionAsync_MissingKey_ReturnsNull()
+ {
+ var store = CreateStore();
+
+ var result = await store.GetSessionAsync("does-not-exist");
+
+ result.Should().BeNull();
+ }
+
+ [TestMethod]
+ public async Task GetSessionAsync_ReturnsTheStoredInstance()
+ {
+ var store = CreateStore();
+ var session = new Session { ["v"] = 1 };
+ await store.AddOrUpdateSessionAsync("key", session);
+
+ var loaded = await store.GetSessionAsync("key");
+
+ loaded.Should().BeSameAs(session);
+ }
+
+ [TestMethod]
+ public async Task GetSessionAsync_ChangesMadeInPlace_AreVisibleToLaterReads()
+ {
+ var store = CreateStore();
+ await store.AddOrUpdateSessionAsync("key", new Session());
+
+ (await store.GetSessionAsync("key"))!["v"] = "changed";
+ var loaded = await store.GetSessionAsync("key");
+
+ loaded!["v"].Should().Be("changed");
+ }
+
+ [TestMethod]
+ public async Task GetSessionAsync_NotInMemory_LoadsFromTable()
+ {
+ await CreateStore().AddOrUpdateSessionAsync("key", new Session { ["greeting"] = "hello" });
+ var restarted = CreateStore();
+
+ var first = await restarted.GetSessionAsync("key");
+ var second = await restarted.GetSessionAsync("key");
+
+ first.Should().NotBeNull();
+ first!["greeting"].Should().Be("hello");
+ second.Should().BeSameAs(first);
+ }
+
+ [TestMethod]
+ public async Task AddOrUpdateSessionAsync_Twice_WritesLatestToTable()
+ {
+ var store = CreateStore();
+
+ await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 });
+ await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 2 });
+ var loaded = await CreateStore().GetSessionAsync("key");
+
+ loaded!["v"].Should().Be(2);
+ }
+
+ [TestMethod]
+ public async Task SessionsWithSameKey_AreIsolatedByDiscriminator()
+ {
+ await CreateStore("app-a").AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "a" });
+ await CreateStore("app-b").AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "b" });
+
+ (await CreateStore("app-a").GetSessionAsync("shared-key"))!["app"].Should().Be("a");
+ (await CreateStore("app-b").GetSessionAsync("shared-key"))!["app"].Should().Be("b");
+ }
+
+ [TestMethod]
+ public async Task PurgeExpiredSessionsAsync_RemovesOnlyThisApplicationsStaleRows()
+ {
+ var store = CreateStore();
+ await AddStaleEntityAsync("stale", Discriminator);
+ await AddStaleEntityAsync("stale-other-app", "other-app");
+ await store.AddOrUpdateSessionAsync("fresh", new Session { ["v"] = 1 });
+
+ await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1));
+
+ (await EntityExistsAsync("stale")).Should().BeFalse();
+ (await EntityExistsAsync("stale-other-app", "other-app")).Should().BeTrue();
+ (await EntityExistsAsync("fresh")).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task PurgeExpiredSessionsAsync_RefreshesLastTouchedWithoutWritingData()
+ {
+ var store = CreateStore();
+ var session = new Session { ["v"] = 1 };
+ await store.AddOrUpdateSessionAsync("key", session);
+ await _serviceClient.GetTableClient(_tableName).UpdateEntityAsync(
+ new TableEntity("key", Discriminator) { ["LastTouched"] = OneDayAgo }, Azure.ETag.All, TableUpdateMode.Merge);
+ session["v"] = 2;
+
+ await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1));
+
+ (await GetEntityAsync("key"))!.GetInt64("LastTouched").Should().Be(session.LastTouched);
+ (await CreateStore().GetSessionAsync("key"))!["v"].Should().Be(1);
+ }
+
+ [TestMethod]
+ public async Task AddOrUpdateSessionAsync_ConcurrentSameKey_CacheAndTableAgree()
+ {
+ var store = CreateStore();
+
+ await Task.WhenAll(Enumerable.Range(0, 20).Select(i =>
+ store.AddOrUpdateSessionAsync("key", new Session { ["v"] = i }).AsTask()));
+
+ var cached = await store.GetSessionAsync("key");
+ var stored = await CreateStore().GetSessionAsync("key");
+ stored!["v"].Should().Be(cached!["v"]);
+ }
+
+ [TestMethod]
+ public async Task AddOrUpdateSessionAsync_LargerThanOneColumn_RoundTrips()
+ {
+ var session = CreateSessionOfSize(200 * 1024);
+
+ await CreateStore().AddOrUpdateSessionAsync("key", session);
+ var loaded = await CreateStore().GetSessionAsync("key");
+
+ (await GetEntityAsync("key"))!.ContainsKey("Value3").Should().BeTrue();
+ loaded!["payload"].Should().Be(session["payload"]);
+ }
+
+ [TestMethod]
+ public async Task AddOrUpdateSessionAsync_NearSizeLimit_RoundTrips()
+ {
+ var session = CreateSessionOfSize(AzureTableStorageSessionStore.MaxChunks * AzureTableStorageSessionStore.ChunkSize - 4096);
+
+ await CreateStore().AddOrUpdateSessionAsync("key", session);
+ var loaded = await CreateStore().GetSessionAsync("key");
+
+ loaded!["payload"].Should().Be(session["payload"]);
+ }
+
+ [TestMethod]
+ public async Task AddOrUpdateSessionAsync_OverSizeLimit_ThrowsAndDoesNotCache()
+ {
+ var store = CreateStore();
+ var session = CreateSessionOfSize(AzureTableStorageSessionStore.MaxChunks * AzureTableStorageSessionStore.ChunkSize + 1024);
+
+ var act = async () => await store.AddOrUpdateSessionAsync("key", session);
+
+ await act.Should().ThrowAsync();
+ (await store.GetSessionAsync("key")).Should().BeNull();
+ }
+
+ [TestMethod]
+ public async Task AddOrUpdateSessionAsync_SmallerSession_RemovesLeftoverColumns()
+ {
+ var store = CreateStore();
+ await store.AddOrUpdateSessionAsync("key", CreateSessionOfSize(200 * 1024));
+
+ await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 });
+
+ (await GetEntityAsync("key"))!.ContainsKey("Value1").Should().BeFalse();
+ (await CreateStore().GetSessionAsync("key"))!["v"].Should().Be(1);
+ }
+
+ [TestMethod]
+ public async Task PurgeExpiredSessionsAsync_RemovesExpiredSessionsFromMemoryAndTable()
+ {
+ var store = CreateStore();
+ await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 });
+
+ _timeProvider.Advance(TimeSpan.FromHours(2));
+ await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1));
+
+ (await EntityExistsAsync("key")).Should().BeFalse();
+ (await store.GetSessionAsync("key")).Should().BeNull();
+ }
+
+ [TestMethod]
+ public async Task Store_ResolvedWithScopeValidation_HandlesConcurrentOperations()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddCsla(o => o.UseAzureTableStorageSessionStore(s =>
+ {
+ s.ConnectionString = DevelopmentConnectionString;
+ s.TableName = _tableName;
+ s.ApplicationDiscriminator = Discriminator;
+ }));
+ using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
+ var store = provider.GetRequiredService();
+ var keys = Enumerable.Range(0, 20).Select(i => $"key-{i}").ToList();
+
+ await Task.WhenAll(keys.Select(k => store.AddOrUpdateSessionAsync(k, new Session { ["k"] = k }).AsTask()));
+ var restarted = CreateStore();
+ var loaded = await Task.WhenAll(keys.Select(k => restarted.GetSessionAsync(k).AsTask()));
+
+ loaded.Select(s => s!["k"]).Should().Equal(keys);
+ }
+}
diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs
new file mode 100644
index 0000000000..34699b070d
--- /dev/null
+++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs
@@ -0,0 +1,155 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) Marimer LLC. All rights reserved.
+// Website: https://cslanet.com
+//
+// CI-safe DI registration tests for the Azure Table Storage session store
+//-----------------------------------------------------------------------
+
+using Azure.Data.Tables;
+using FluentAssertions;
+using Csla.Configuration;
+using Csla.State;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Time.Testing;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Csla.Blazor.State.AzureTableStorage.Tests;
+
+///
+/// Verifies the registrations made by .
+/// No test resolves the session store itself, because its constructor contacts
+/// Azure Storage; creating a does not.
+///
+[TestClass]
+public class ConfigurationExtensionsTests
+{
+ private const string DevelopmentConnectionString = "UseDevelopmentStorage=true";
+
+ [TestMethod]
+ public void UseAzureTableStorageSessionStore_RegistersSingletonISessionStore()
+ {
+ var services = new ServiceCollection();
+
+ new CslaOptions(services).UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString);
+
+ var descriptor = services.Single(d => d.ServiceType == typeof(ISessionStore));
+ descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton);
+ descriptor.ImplementationFactory.Should().NotBeNull();
+ }
+
+ [TestMethod]
+ public void UseAzureTableStorageSessionStore_AfterAddServerSideBlazor_IsLastISessionStoreRegistration()
+ {
+ var services = new ServiceCollection();
+
+ services.AddCsla(o => o
+ .AddServerSideBlazor(b => b.UseInMemoryApplicationContextManager = false)
+ .UseAzureTableStorageSessionStore(s => s.ConnectionString = DevelopmentConnectionString));
+
+ var descriptor = services.Last(d => d.ServiceType == typeof(ISessionStore));
+ descriptor.ImplementationType.Should().BeNull();
+ descriptor.ImplementationFactory.Should().NotBeNull();
+ }
+
+ [TestMethod]
+ public void UseAzureTableStorageSessionStore_RegistersConfiguredOptionsAsSingleton()
+ {
+ var services = new ServiceCollection();
+
+ new CslaOptions(services).UseAzureTableStorageSessionStore(o =>
+ {
+ o.ConnectionString = DevelopmentConnectionString;
+ o.TableName = "CustomTable";
+ });
+
+ var descriptor = services.Single(d => d.ServiceType == typeof(AzureTableStorageOptions));
+ descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton);
+ ((AzureTableStorageOptions)descriptor.ImplementationInstance!).TableName.Should().Be("CustomTable");
+ }
+
+ [TestMethod]
+ public void UseAzureTableStorageSessionStore_KeepsRegisteredTimeProvider()
+ {
+ var services = new ServiceCollection();
+ var timeProvider = new FakeTimeProvider();
+ services.AddSingleton(timeProvider);
+
+ new CslaOptions(services).UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString);
+
+ services.Single(d => d.ServiceType == typeof(TimeProvider)).ImplementationInstance.Should().BeSameAs(timeProvider);
+ }
+
+ [TestMethod]
+ public void UseAzureTableStorageSessionStore_DoesNotRegisterTableServiceClient()
+ {
+ var services = new ServiceCollection();
+
+ new CslaOptions(services).UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString);
+
+ services.Should().NotContain(d => d.ServiceType == typeof(TableServiceClient));
+ }
+
+ [TestMethod]
+ public void UseAzureTableStorageSessionStore_ReturnsSameConfigInstance()
+ {
+ var config = new CslaOptions(new ServiceCollection());
+
+ var result = config.UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString);
+
+ result.Should().BeSameAs(config);
+ }
+
+ [TestMethod]
+ public void UseAzureTableStorageSessionStore_NullConfig_Throws()
+ {
+ CslaOptions config = null!;
+
+ var act = () => config.UseAzureTableStorageSessionStore();
+
+ act.Should().Throw();
+ }
+
+ [TestMethod]
+ public void GetTableServiceClient_ConnectionString_IgnoresRegisteredClient()
+ {
+ var registered = new TableServiceClient(new Uri("https://registered.table.core.windows.net"), new TableSharedKeyCredential("registered", Convert.ToBase64String(new byte[32])));
+ using var provider = new ServiceCollection()
+ .AddSingleton(registered)
+ .BuildServiceProvider();
+ var options = new AzureTableStorageOptions { ConnectionString = DevelopmentConnectionString };
+
+ var client = AzureTableStorageConfigurationExtensions.GetTableServiceClient(provider, options);
+
+ client.Should().NotBeSameAs(registered);
+ client.AccountName.Should().Be("devstoreaccount1");
+ }
+
+ [TestMethod]
+ public void GetTableServiceClient_ServiceKey_ResolvesKeyedClient()
+ {
+ var keyed = new TableServiceClient(DevelopmentConnectionString);
+ using var provider = new ServiceCollection()
+ .AddKeyedSingleton("state", keyed)
+ .AddSingleton(new TableServiceClient(DevelopmentConnectionString))
+ .BuildServiceProvider();
+ var options = new AzureTableStorageOptions { ServiceKey = "state" };
+
+ var client = AzureTableStorageConfigurationExtensions.GetTableServiceClient(provider, options);
+
+ client.Should().BeSameAs(keyed);
+ }
+
+ [TestMethod]
+ public void GetTableServiceClient_NoConnectionStringOrKey_ResolvesDefaultClient()
+ {
+ var registered = new TableServiceClient(DevelopmentConnectionString);
+ using var provider = new ServiceCollection()
+ .AddSingleton(registered)
+ .BuildServiceProvider();
+
+ var client = AzureTableStorageConfigurationExtensions.GetTableServiceClient(provider, new AzureTableStorageOptions());
+
+ client.Should().BeSameAs(registered);
+ }
+}
diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj
new file mode 100644
index 0000000000..64444e5999
--- /dev/null
+++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0;net11.0
+ false
+ true
+ ..\..\Csla\CslaKey.snk
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+