In OfficeFighters, an atlas, avatar or audio file is a dependency of a published fighter definition. Changing bytes at an existing address can combine a cached image with a newer animation layout. The storage design therefore includes identity, versions, audience and validation. This walkthrough follows AssetService, S3ObjectStore, AssetContentReader and the migration command in the inspected source.
Separate metadata from payload storage
GameAsset records describe dimensions, duration, content type, SHA-256 hash, group and content version. Payload bytes live either in game_asset_contents in PostgreSQL or in an S3-compatible object store. StorageOptions selects Database or S3. References between fighters and assets remain relational regardless of where the bytes reside.
The S3ObjectStore adapter signs server requests to the Supabase Storage S3 endpoint. Its configuration contains the service URL, region, bucket, key prefix and credentials. Supabase documents generated S3 keys as server credentials; the project keeps asset delivery behind its own API rather than handing those keys to the browser.
Validate the actual upload
UploadValidator bounds the input length, identifies the format from its signature and checks that the format is allowed for the asset type. Container inspection catches structural problems, animation and trailing data. Avatar re-encoding has a separate policy. A PNG filename alone therefore does not decide whether an upload is acceptable. Atlas dimensions and cell geometry also determine whether the game can use the result.
Store the object before committing metadata
The abbreviated control flow below omits deduplication, auditing and error mapping, but preserves the ordering in StoreCore.
// AssetService.StoreCore: simplified storage/write order.
if (storage.Value.UsesS3)
await objects.PutAsync(id, content.Bytes, content.ContentType, ct);
else
db.GameAssetContents.Add(new GameAssetContent {
AssetId = id, Data = content.Bytes
});
// GameAsset metadata is tracked in the same DbContext.
var saved = await DbWrite.SaveAsync(db, ct);An object store write and a PostgreSQL commit are separate operations. If the object succeeds and metadata persistence fails, an unreferenced object can remain. This order avoids making a newly created metadata record visible before its content exists. Cleanup is a separate operation; the repository includes a deletion command that protects objects whose database asset rows still exist.
Deduplication needs an ownership boundary
A matching type and content hash can return an existing asset. Adding a version restricts that lookup to its asset group. Avatars additionally match the uploader and audience. Without this boundary, cleanup for one account could affect another account that happened to upload the same bytes. Asset identity is therefore more than a hash.
Player-facing assets are served by GUID with ETag and a one-year immutable public cache policy. Editor assets use authenticated XHR and blob URLs. Public delivery here describes the API response policy; the backing bucket can remain private. New versions receive their own asset identity so long-lived caching remains compatible with publication.
Retained immutable content supports a fallback
The reader prefers S3. A missing object can fall back to retained database bytes. A storage exception also permits this fallback, with a warning; if no retained copy exists, the exception is preserved.
// AssetReadFallback: the retained copy must be immutable.
try {
if (await preferred() is { } stored) return stored;
}
catch (AssetStoreException) {
var copy = await retained();
if (copy is null) throw;
onOutageFallback();
return copy;
}
return await retained();This behavior depends on the bytes being immutable for that asset identity. New S3 uploads do not automatically receive a second database copy. The fallback is useful where older retained content is actually available. Database reads return an owned stream that disposes its reader, command and connection together.
Understand the migration evidence
The migrator treats an existing object with the expected size as already stored. For an upload, it reads the retained bytes and checks SHA-256 against metadata before writing, then checks remote length. Length equality is narrower evidence than independently hashing a remote download. An operational report should preserve that distinction.
Removing retained database content requires an explicit purge option. Every application instance sharing the database must be able to read S3 first. Useful failure scenarios include a missing object, an unavailable store with and without a retained copy, mismatched source hashes and a failed metadata save. Together they explain the real consistency boundary of this implementation.
Kaynaklar / References
More in the OfficeFighters series
- OfficeFighters — real-time game communication with SignalR and WebSocket
- OfficeFighters — Phaser, fixed-step simulation and snapshot interpolation
- OfficeFighters — resilient email delivery with MailKit and an outbox
- OfficeFighters — JWT, refresh-token rotation and multi-tab session management
- OfficeFighters — PostgreSQL, EF Core and a DB-first data model