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
2 changes: 2 additions & 0 deletions SquidStd.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
<Project Path="src/SquidStd.Crypto/SquidStd.Crypto.csproj"/>
<Project Path="src/SquidStd.Vfs.Abstractions/SquidStd.Vfs.Abstractions.csproj"/>
<Project Path="src/SquidStd.Vfs/SquidStd.Vfs.csproj"/>
<Project Path="src/SquidStd.Vfs.S3/SquidStd.Vfs.S3.csproj"/>
<Project Path="src/SquidStd.Vfs.Database/SquidStd.Vfs.Database.csproj"/>
<Project Path="src/SquidStd.Secrets.Aws/SquidStd.Secrets.Aws.csproj"/>
<Project Path="src/SquidStd.Tui/SquidStd.Tui.csproj"/>
</Folder>
Expand Down
42 changes: 42 additions & 0 deletions src/SquidStd.Vfs.Abstractions/DeferredWriteStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace SquidStd.Vfs.Abstractions;

/// <summary>
/// A writable in-memory stream that flushes its accumulated bytes through a callback exactly once on
/// disposal. Backs <c>OpenWriteAsync</c> for filesystems that persist the whole payload at close time.
/// </summary>
public sealed class DeferredWriteStream : MemoryStream
{
private readonly Func<byte[], CancellationToken, ValueTask> _onFlush;
private readonly CancellationToken _cancellationToken;
private bool _flushed;

public DeferredWriteStream(Func<byte[], CancellationToken, ValueTask> onFlush, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(onFlush);

_onFlush = onFlush;
_cancellationToken = cancellationToken;
}

public override async ValueTask DisposeAsync()
{
if (!_flushed)
{
_flushed = true;
await _onFlush(ToArray(), _cancellationToken).ConfigureAwait(false);
}

await base.DisposeAsync().ConfigureAwait(false);
}

protected override void Dispose(bool disposing)
{
if (disposing && !_flushed)
{
_flushed = true;
_onFlush(ToArray(), _cancellationToken).AsTask().GetAwaiter().GetResult();
}

base.Dispose(disposing);
}
}
18 changes: 18 additions & 0 deletions src/SquidStd.Vfs.Database/Data/Entities/VfsFileEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using FreeSql.DataAnnotations;
using SquidStd.Database.Abstractions.Data.Entities;

namespace SquidStd.Vfs.Database.Data.Entities;

/// <summary>A stored file keyed by its logical VFS path.</summary>
[Index("uq_vfs_file_path", "Path", true)]
public sealed class VfsFileEntity : BaseEntity
{
/// <summary>The logical VFS path (unique).</summary>
public string Path { get; set; } = string.Empty;

/// <summary>The file bytes.</summary>
public byte[] Content { get; set; } = [];

/// <summary>The file size in bytes.</summary>
public long Size { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using DryIoc;
using SquidStd.Database.Abstractions.Interfaces.Data;
using SquidStd.Vfs.Abstractions.Interfaces;
using SquidStd.Vfs.Database.Data.Entities;
using SquidStd.Vfs.Database.Services;

namespace SquidStd.Vfs.Database.Extensions;

/// <summary>DryIoc registration helper for the database VFS backend.</summary>
public static class RegisterDatabaseFileSystemExtensions
{
/// <param name="container">Container that receives the VFS registration.</param>
extension(IContainer container)
{
/// <summary>
/// Registers an <see cref="IVirtualFileSystem" /> backed by the database.
/// Requires the SquidStd.Database module to be registered (it supplies <c>IDataAccess&lt;&gt;</c>).
/// </summary>
public IContainer RegisterDatabaseFileSystem()
{
container.RegisterDelegate<IVirtualFileSystem>(
r => new DatabaseFileSystem(r.Resolve<IDataAccess<VfsFileEntity>>()),
Reuse.Singleton
);

return container;
}
}
}
55 changes: 55 additions & 0 deletions src/SquidStd.Vfs.Database/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<h1 align="center">SquidStd.Vfs.Database</h1>

An `IVirtualFileSystem` that stores files as rows in a relational database via SquidStd.Database / FreeSql.
Each file is a single `VfsFileEntity` row keyed by path; writes are upserts (last write wins, no concurrent
write safety guarantee).

## Install

```bash
dotnet add package SquidStd.Vfs.Database
```

## Usage

Register `SquidStd.Database` first (it provides `IDataAccess<>`), then add the VFS backend:

```csharp
using DryIoc;
using SquidStd.Vfs.Abstractions.Interfaces;
using SquidStd.Vfs.Database.Extensions;

// SquidStd.Database must already be registered on the container.
container.RegisterDatabaseFileSystem();

var fs = container.Resolve<IVirtualFileSystem>();

await fs.WriteAllBytesAsync("config/settings.json", payload);
byte[]? data = await fs.ReadAllBytesAsync("config/settings.json");

await foreach (var entry in fs.ListAsync("config"))
Console.WriteLine($"{entry.Path} ({entry.Size} bytes)");
```

The `VfsFileEntity` table is created automatically by FreeSql on first access (sync-structure mode).

> **Single-writer assumption** — upsert operations are not serialised across concurrent writers. Use this
> backend in single-process scenarios or where last-write-wins is acceptable.

## Key types

| Type | Purpose |
|---|---|
| `RegisterDatabaseFileSystemExtensions` | `RegisterDatabaseFileSystem()` DryIoc registration. |
| `DatabaseFileSystem` | FreeSql-backed `IVirtualFileSystem`. |
| `VfsFileEntity` | ORM entity: `Path` (PK), `Content` (blob), `UpdatedAt`. |

## Related

- Tutorial: [Virtual filesystem](https://tgiachi.github.io/squid-std/tutorials/vfs.html)
- [`SquidStd.Vfs`](../SquidStd.Vfs/README.md) — core backends and decorators
- [`SquidStd.Database`](../SquidStd.Database/README.md) — required database module

## License

MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
120 changes: 120 additions & 0 deletions src/SquidStd.Vfs.Database/Services/DatabaseFileSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System.Runtime.CompilerServices;
using SquidStd.Database.Abstractions.Interfaces.Data;
using SquidStd.Vfs.Abstractions;
using SquidStd.Vfs.Abstractions.Data;
using SquidStd.Vfs.Abstractions.Interfaces;
using SquidStd.Vfs.Database.Data.Entities;

namespace SquidStd.Vfs.Database.Services;

/// <summary>
/// A <see cref="IVirtualFileSystem" /> implementation that stores files as rows in a relational
/// database via the generic <see cref="IDataAccess{TEntity}" /> abstraction (FreeSql).
/// </summary>
public sealed class DatabaseFileSystem : IVirtualFileSystem
{
private readonly IDataAccess<VfsFileEntity> _data;

/// <summary>
/// Initializes the database filesystem.
/// </summary>
/// <param name="dataAccess">The data access for <see cref="VfsFileEntity" /> rows.</param>
public DatabaseFileSystem(IDataAccess<VfsFileEntity> dataAccess)
{
_data = dataAccess;
}

/// <inheritdoc />
public async ValueTask<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);

return await _data.ExistsAsync(e => e.Path == path, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
public async ValueTask<byte[]?> ReadAllBytesAsync(string path, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);

var rows = await _data.QueryAsync(e => e.Path == path, cancellationToken).ConfigureAwait(false);

return rows.Count > 0 ? rows[0].Content : null;
}

/// <inheritdoc />
public async ValueTask WriteAllBytesAsync(
string path,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default
)
{
ArgumentException.ThrowIfNullOrEmpty(path);

var rows = await _data.QueryAsync(e => e.Path == path, cancellationToken).ConfigureAwait(false);
var existing = rows.Count > 0 ? rows[0] : null;
var bytes = data.ToArray();

if (existing is not null)
{
existing.Content = bytes;
existing.Size = bytes.Length;
await _data.UpdateAsync(existing, cancellationToken).ConfigureAwait(false);
}
else
{
await _data.InsertAsync(
new VfsFileEntity { Path = path, Content = bytes, Size = bytes.Length },
cancellationToken
).ConfigureAwait(false);
}
}

/// <inheritdoc />
public async Task<Stream> OpenReadAsync(string path, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);

var bytes = await ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false)
?? throw new FileNotFoundException($"No file at '{path}'.", path);

return new MemoryStream(bytes, writable: false);
}

/// <inheritdoc />
public Task<Stream> OpenWriteAsync(string path, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);

return Task.FromResult<Stream>(
new DeferredWriteStream(
(bytes, ct) => WriteAllBytesAsync(path, bytes, ct),
cancellationToken
)
);
}

/// <inheritdoc />
public async ValueTask<bool> DeleteAsync(string path, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);

return await _data.BulkDeleteAsync(e => e.Path == path, cancellationToken).ConfigureAwait(false) > 0;
}

/// <inheritdoc />
public async IAsyncEnumerable<VfsEntry> ListAsync(
string? prefix = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
var rows = string.IsNullOrEmpty(prefix)
? await _data.QueryAsync(cancellationToken: cancellationToken).ConfigureAwait(false)
: await _data.QueryAsync(e => e.Path.StartsWith(prefix), cancellationToken).ConfigureAwait(false);

foreach (var e in rows)
{
yield return new VfsEntry(e.Path, e.Size, e.Updated);
}
}
}
20 changes: 20 additions & 0 deletions src/SquidStd.Vfs.Database/SquidStd.Vfs.Database.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<ItemGroup>
<ProjectReference Include="..\SquidStd.Core\SquidStd.Core.csproj"/>
<ProjectReference Include="..\SquidStd.Database.Abstractions\SquidStd.Database.Abstractions.csproj"/>
<ProjectReference Include="..\SquidStd.Vfs.Abstractions\SquidStd.Vfs.Abstractions.csproj"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="FreeSql" Version="3.5.310"/>
</ItemGroup>

</Project>
13 changes: 13 additions & 0 deletions src/SquidStd.Vfs.S3/Data/S3FileSystemOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using SquidStd.Aws.Abstractions.Data.Config;

namespace SquidStd.Vfs.S3.Data;

/// <summary>Connection options for the S3-compatible VFS backend (AWS / MinIO / R2 / B2).</summary>
public sealed class S3FileSystemOptions
{
/// <summary>AWS-style connection config; <c>ServiceUrl</c> is the full endpoint for S3-compatibles.</summary>
public AwsConfigEntry Aws { get; init; } = new();

/// <summary>Bucket name.</summary>
public string Bucket { get; init; } = string.Empty;
}
26 changes: 26 additions & 0 deletions src/SquidStd.Vfs.S3/Extensions/RegisterS3FileSystemExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using DryIoc;
using SquidStd.Vfs.Abstractions.Interfaces;
using SquidStd.Vfs.S3.Data;
using SquidStd.Vfs.S3.Services;

namespace SquidStd.Vfs.S3.Extensions;

/// <summary>DryIoc registration helper for the S3-compatible VFS backend.</summary>
public static class RegisterS3FileSystemExtensions
{
/// <param name="container">Container that receives the VFS registration.</param>
extension(IContainer container)
{
/// <summary>Registers an <see cref="IVirtualFileSystem" /> backed by S3-compatible storage.</summary>
public IContainer RegisterS3FileSystem(Action<S3FileSystemOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);

var options = new S3FileSystemOptions();
configure(options);
container.RegisterDelegate<IVirtualFileSystem>(_ => new S3FileSystem(options), Reuse.Singleton);

return container;
}
}
}
55 changes: 55 additions & 0 deletions src/SquidStd.Vfs.S3/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<h1 align="center">SquidStd.Vfs.S3</h1>

An `IVirtualFileSystem` over S3-compatible object storage — AWS S3, MinIO, Cloudflare R2, Backblaze B2 — via
the MinIO .NET SDK. Each logical path maps directly to an object key inside a single bucket. The bucket is
created lazily on first use.

## Install

```bash
dotnet add package SquidStd.Vfs.S3
```

## Usage

```csharp
using DryIoc;
using SquidStd.Vfs.Abstractions.Interfaces;
using SquidStd.Vfs.S3.Extensions;

container.RegisterS3FileSystem(o =>
{
o.Bucket = "app-data";
o.Aws.ServiceUrl = "https://s3.amazonaws.com"; // or MinIO/R2/B2 endpoint
o.Aws.AccessKey = "...";
o.Aws.SecretKey = "...";
});

var fs = container.Resolve<IVirtualFileSystem>();

await fs.WriteAllBytesAsync("reports/2026.json", payload);
byte[]? data = await fs.ReadAllBytesAsync("reports/2026.json");

await foreach (var entry in fs.ListAsync("reports"))
Console.WriteLine($"{entry.Path} ({entry.Size} bytes)");
```

For native AWS with the default credential chain, omit `AccessKey`/`SecretKey` and set only the region via
`o.Aws.Region`.

## Key types

| Type | Purpose |
|---|---|
| `RegisterS3FileSystemExtensions` | `RegisterS3FileSystem(...)` DryIoc registration. |
| `S3FileSystem` | MinIO-backed `IVirtualFileSystem` with lazy bucket creation. |
| `S3FileSystemOptions` | `Bucket` + `Aws` (`ServiceUrl`, `AccessKey`, `SecretKey`, `Region`). |

## Related

- Tutorial: [Virtual filesystem](https://tgiachi.github.io/squid-std/tutorials/vfs.html)
- [`SquidStd.Vfs`](../SquidStd.Vfs/README.md) — core backends and decorators

## License

MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
Loading
Loading