Skip to content

Themia 0.5.3 — Themia.Modules.Storage (Local + S3/R2 object storage, tenant metadata + quota)#105

Merged
klomkling merged 30 commits into
mainfrom
feat/storage-0.5.3
Jun 17, 2026
Merged

Themia 0.5.3 — Themia.Modules.Storage (Local + S3/R2 object storage, tenant metadata + quota)#105
klomkling merged 30 commits into
mainfrom
feat/storage-0.5.3

Conversation

@klomkling

Copy link
Copy Markdown
Owner

Summary

The last Phase-1 cross-cutting module: tenant-aware object storage, shipped as three packages following the established neutral-core → provider → module topology.

  • Themia.Storage (neutral, net8;net10) — IStorageProvider abstraction + Local filesystem backend with HMAC-signed presigned URLs.
  • Themia.Storage.S3 (neutral, net8;net10) — S3 + Cloudflare R2 backend (R2 via configured endpoint + path-style).
  • Themia.Modules.Storage (net10) — tenant key-prefix isolation (StorageScope), DB-backed metadata + per-tenant quota over EF Core or Dapper (selectable peers) on one FluentMigrator schema (PostgreSQL + SQL Server), IFileValidator/IFileScanner seams, the AddThemiaStorage().UseLocal/UseS3/UseR2 builder, and opt-in MapThemiaStorageEndpoints (presigned-direct for S3/R2; module-served signed routes for Local).

Built spec-first (docs/superpowers/specs/2026-06-17-themia-storage-design.md) → 17-task plan (docs/superpowers/plans/2026-06-17-themia-storage-0.5.3.md) → subagent-driven TDD execution.

Key decisions (locked in the spec)

  • First slice = core + Local + S3/R2 together (R2 is ~free once S3 exists).
  • DB-backed metadata + transactional, metadata-first quota (best-effort under high concurrency; strict serialization deferred to 0.5.5).
  • Virus scanning deferred (IFileScanner no-op seam now; ClamAV → 0.5.4).
  • Tenant isolation by key-prefixing in a shared bucket/root.

Notable fixes caught during review

  • S3 v4 presign ignores constructor credentials — set AmazonS3Config.DefaultAWSCredentials (else R2/S3 presigned URLs break for explicit-key adopters).
  • Failed overwrite no longer soft-deletes the pre-existing object (restores prior metadata).
  • EF/Dapper IncludeGlobalRecordsForTenants splitTenantStorage now strictly scope-matches metadata so platform objects never cross-count or cross-surface to a tenant (parity across both peers).
  • SQL Server key reserved-word quoting in the migration; Dapper ETagetag column override; soft-deleted rows excluded from the unique index so delete→reupload works.

Test plan

  • Full solution build: 0 warnings / 0 errors (TreatWarningsAsErrors, PublicAPI tracked).
  • Unit: Themia.Storage.Tests (10), Themia.Modules.Storage.Tests (13).
  • Provider conformance vs Testcontainers MinIO + Local: Themia.Storage.IntegrationTests (8).
  • Module 4-way conformance (EF×Dapper × PostgreSQL×SQL Server): *.EFCore.IntegrationTests (24) + *.Dapper.SqlServer.IntegrationTests (8) — metadata, quota enforcement, overwrite-not-double-counting, tenant isolation, platform-invisibility, reupload-after-delete.
  • Local signed routes over real HTTP: *.AspNetCore.IntegrationTests (3 — round-trip, tampered token 401, expired token 401).

Deferred (per spec §11/§12)

ClamAV scanner (0.5.4), magic-byte sniffing, server-side multipart, per-tenant quota overrides + strict serialization, orphan-blob reconcile, raw-provider-bypass analyzer (0.5.5); Azure Blob/GCS providers and per-tenant-bucket isolation (later, YAGNI).

klomkling added 30 commits June 17, 2026 14:44
…AWSCredentials

AWS SDK for .NET v4 resolves presigned-URL credentials from
Config.DefaultAWSCredentials (then the default identity chain), ignoring the
credentials passed to the AmazonS3Client constructor. Both the sync
GetPreSignedURL and async GetPreSignedURLAsync paths behave this way, so
switching to the async API alone does not help. Set Config.DefaultAWSCredentials
when explicit keys are configured (UseS3/UseR2; Cloudflare R2 always uses them)
so presigned URLs sign correctly. Drops the env-var workaround in the
conformance test.
Adds the EF×Dapper × Postgres×SqlServer integration conformance harness
(shared base + EFCore and Dapper.SqlServer concrete projects, Local backend).

Two module bugs surfaced and fixed (conformance was red without them):
- StorageSchemaMigration: the filtered-unique-index SQL referenced the
  'key' column unquoted, which is a reserved word on SQL Server. Quote it
  per engine ([key] / "key").
- StorageDapperMappings: the snake_case convention maps ETag -> e_tag, but
  the schema and EF config use 'etag'. Add an explicit column override.
…form tenant id, best-effort delete + logging, tests
…S3 dispose ownership, migration schema drop, doc polish
…dates; cap _local/put size; compensate on uncancellable token
…ner, shared key validator, length-contract doc, simplify spec
…al overwrite, sidecar namespace isolation, signer expiry range guard

Address Agy/Gemini review findings:
- C1: StorageObjectByKeySpec/AllStorageObjectsSpec now filter o.TenantId == ambient at the
  query level. The InScope post-filter alone was insufficient — FirstOrDefault could return a
  platform (tenant_id IS NULL) row sharing the key, masking the tenant's own row (Get/Exists/
  Delete) and 500ing ReserveAsync on a unique-constraint insert. Verified to translate on EF + Dapper.
- C3: LocalStorageProvider.PutAsync writes to a temp file then File.Move(overwrite) — a mid-write
  failure no longer truncates/corrupts the existing object (was FileMode.Create).
- I1: blobs and content-type sidecars live in separate {root}/blobs and {root}/content-types
  subtrees, so a key like 'foo.contenttype' can't overwrite object 'foo'\''s content type.
- I2: LocalUrlSigner.TryVerify range-guards the client expiry before FromUnixTimeSeconds (was an
  unhandled ArgumentOutOfRangeException -> 500).
…ual size + quota, hide pending reservations (closes C2/I3)
@klomkling klomkling merged commit a5a675a into main Jun 17, 2026
3 checks passed
@klomkling klomkling deleted the feat/storage-0.5.3 branch June 17, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant