Themia 0.5.3 — Themia.Modules.Storage (Local + S3/R2 object storage, tenant metadata + quota)#105
Merged
Conversation
…2, DB metadata + quota, slice roadmap)
…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.
…r global-records parity)
…form tenant id, best-effort delete + logging, tests
…ning key; normalize empty content type
…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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) —IStorageProviderabstraction + 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/IFileScannerseams, theAddThemiaStorage().UseLocal/UseS3/UseR2builder, and opt-inMapThemiaStorageEndpoints(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)
IFileScannerno-op seam now; ClamAV → 0.5.4).Notable fixes caught during review
AmazonS3Config.DefaultAWSCredentials(else R2/S3 presigned URLs break for explicit-key adopters).IncludeGlobalRecordsForTenantssplit —TenantStoragenow strictly scope-matches metadata so platform objects never cross-count or cross-surface to a tenant (parity across both peers).keyreserved-word quoting in the migration; DapperETag→etagcolumn override; soft-deleted rows excluded from the unique index so delete→reupload works.Test plan
Themia.Storage.Tests(10),Themia.Modules.Storage.Tests(13).Themia.Storage.IntegrationTests(8).*.EFCore.IntegrationTests(24) +*.Dapper.SqlServer.IntegrationTests(8) — metadata, quota enforcement, overwrite-not-double-counting, tenant isolation, platform-invisibility, reupload-after-delete.*.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).