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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions src/AzLocal.State/AzLocal.State.csproj
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\AzLocal.Core\AzLocal.Core.csproj" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\AzLocal.Core\AzLocal.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
106 changes: 104 additions & 2 deletions src/AzLocal.State/SqliteStateStore.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,107 @@
namespace AzLocal.State;
using AzLocal.Core.Interfaces;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using System.Text.Json;

public class SqliteStateStore
namespace AzLocal.State;

/// <summary>
/// SQLite-backed implementation of <see cref="IStateStore"/>. State is persisted to a
/// <c>state.db</c> file and survives process restarts.
/// Database file defaults to <c>%TEMP%/azlocal/state.db</c> unless overridden via
/// <c>AzLocal:SqlitePath</c> in configuration.
/// </summary>
public class SqliteStateStore : IStateStore
{
private readonly string _connectionString;

public SqliteStateStore(IConfiguration config)
{
var dir = config["AzLocal:SqlitePath"]
?? Path.Combine(Path.GetTempPath(), "azlocal");
Directory.CreateDirectory(dir);
_connectionString = $"Data Source={Path.Combine(dir, "state.db")}";
EnsureTable();
}

/// <summary>Returns the entry deserialized as <typeparamref name="T"/>, or null if the key does not exist.</summary>
public async Task<T?> GetAsync<T>(string key)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT value FROM store WHERE key = $key";
cmd.Parameters.AddWithValue("$key", key.ToLowerInvariant());
var result = await cmd.ExecuteScalarAsync();
return result is string json ? JsonSerializer.Deserialize<T>(json) : default;
}

/// <summary>Serializes <paramref name="value"/> to JSON and upserts it under <paramref name="key"/>.</summary>
public async Task SetAsync<T>(string key, T value)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
// INSERT OR UPDATE in one atomic statement — no separate exists check needed.
cmd.CommandText = @"INSERT INTO store (key, value) VALUES ($key, $value)
ON CONFLICT(key) DO UPDATE SET value = $value";
cmd.Parameters.AddWithValue("$key", key.ToLowerInvariant());
cmd.Parameters.AddWithValue("$value", JsonSerializer.Serialize(value));
await cmd.ExecuteNonQueryAsync();
}

/// <summary>Removes the entry with the given key. No-ops if the key does not exist.</summary>
public async Task DeleteAsync(string key)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM store WHERE key = $key";
cmd.Parameters.AddWithValue("$key", key.ToLowerInvariant());
await cmd.ExecuteNonQueryAsync();
}

/// <summary>Returns all entries whose keys start with <paramref name="prefix"/>, deserialized as <typeparamref name="T"/>.</summary>
public async Task<IReadOnlyList<T>> ListAsync<T>(string prefix)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
// LIKE with trailing % uses the PRIMARY KEY index — efficient prefix scan.
cmd.CommandText = "SELECT value FROM store WHERE key LIKE $prefix";
cmd.Parameters.AddWithValue("$prefix", prefix.ToLowerInvariant() + "%");
var results = new List<T>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var item = JsonSerializer.Deserialize<T>(reader.GetString(0));
if (item is not null) results.Add(item);
}
return results;
}

/// <summary>Returns true if an entry with the given key exists.</summary>
public async Task<bool> ExistsAsync(string key)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(1) FROM store WHERE key = $key";
cmd.Parameters.AddWithValue("$key", key.ToLowerInvariant());
return Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0;
}

#region Private helpers

// Creates the store table on first run. Synchronous because it runs once at startup before any async calls.
private void EnsureTable()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE IF NOT EXISTS store (key TEXT PRIMARY KEY, value TEXT NOT NULL)";
cmd.ExecuteNonQuery();
}

#endregion
}
70 changes: 68 additions & 2 deletions src/AzLocal.State/TempFileBlobStore.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,71 @@
namespace AzLocal.State;
using AzLocal.Core.Interfaces;
using Microsoft.Extensions.Configuration;

public class TempFileBlobStore
namespace AzLocal.State;

/// <summary>
/// File-system implementation of <see cref="IBlobFileStore"/>. Blobs are stored as plain files
/// under <c>%TEMP%/azlocal/blobs/{container}/{blobName}</c> by default, or a custom path via
/// <c>AzLocal:BlobStorePath</c> in configuration.
/// </summary>
public class TempFileBlobStore : IBlobFileStore
{
private readonly string _basePath;

public TempFileBlobStore(IConfiguration config)
{
_basePath = config["AzLocal:BlobStorePath"]
?? Path.Combine(Path.GetTempPath(), "azlocal", "blobs");
Directory.CreateDirectory(_basePath);
}

/// <summary>Writes <paramref name="content"/> to disk as a blob file.</summary>
public async Task WriteAsync(string containerName, string blobName, Stream content, string contentType)
{
var path = BlobPath(containerName, blobName);
// Create intermediate directories for blob names that contain slashes (e.g. "folder/file.txt").
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var file = File.Create(path);
await content.CopyToAsync(file);
}

/// <summary>Opens the blob file for reading. Throws <see cref="FileNotFoundException"/> if it does not exist.</summary>
public Task<Stream> ReadAsync(string containerName, string blobName)
{
var path = BlobPath(containerName, blobName);
if (!File.Exists(path))
throw new FileNotFoundException($"Blob not found: {containerName}/{blobName}");
// OpenRead returns a handle synchronously; actual reading is done by the caller.
return Task.FromResult<Stream>(File.OpenRead(path));
}

/// <summary>Deletes the entire container directory and all blobs inside it. No-ops if the container does not exist.</summary>
public Task DeleteContainerAsync(string containerName)
{
var path = Path.Combine(_basePath, containerName);
if (Directory.Exists(path))
Directory.Delete(path, recursive: true);
return Task.CompletedTask;
}

/// <summary>Returns true if the blob file exists on disk.</summary>
public Task<bool> ExistsAsync(string containerName, string blobName)
{
return Task.FromResult(File.Exists(BlobPath(containerName, blobName)));
}

/// <summary>Deletes the blob file. No-ops if the blob does not exist.</summary>
public Task DeleteAsync(string containerName, string blobName)
{
var path = BlobPath(containerName, blobName);
if (File.Exists(path)) File.Delete(path);
return Task.CompletedTask;
}

#region Private helpers

private string BlobPath(string container, string blob) =>
Path.Combine(_basePath, container, blob);

#endregion
}
Loading