< Summary

Information
Class: Anichron.Core.Data.AnichronDbContext
Assembly: Anichron.Core
File(s): /home/runner/work/anichron/anichron/src/Anichron.Core/Data/AnichronDbContext.cs
Tag: 228_36821334185
Line coverage
93%
Covered lines: 243
Uncovered lines: 16
Coverable lines: 259
Total lines: 281
Line coverage: 93.8%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Users()100%11100%
get_StorageConfigs()100%11100%
get_MediaAssets()100%210%
get_Metadata()100%210%
get_ProxyFiles()100%210%
get_Bursts()100%210%
get_Interactions()100%210%
get_RefreshTokens()100%11100%
get_Invites()100%11100%
ExecuteInTransactionAsync()100%210%
ExecuteInTransactionAsync()100%210%
OnModelCreating(...)100%11100%

File(s)

/home/runner/work/anichron/anichron/src/Anichron.Core/Data/AnichronDbContext.cs

#LineLine coverage
 1using Anichron.Core.Domain;
 2using Microsoft.EntityFrameworkCore;
 3
 4namespace Anichron.Core.Data;
 5
 476public class AnichronDbContext(DbContextOptions<AnichronDbContext> options) : DbContext(options), IUnitOfWork
 7{
 478    public DbSet<User> Users => Set<User>();
 259    public DbSet<UserStorageConfig> StorageConfigs => Set<UserStorageConfig>();
 010    public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
 011    public DbSet<Metadata> Metadata => Set<Metadata>();
 012    public DbSet<ProxyFile> ProxyFiles => Set<ProxyFile>();
 013    public DbSet<Burst> Bursts => Set<Burst>();
 014    public DbSet<AssetInteraction> Interactions => Set<AssetInteraction>();
 815    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
 916    public DbSet<Invite> Invites => Set<Invite>();
 17
 18    public async Task<T> ExecuteInTransactionAsync<T>(Func<Task<T>> action, CancellationToken ct = default)
 019    {
 020        await using var tx = await Database.BeginTransactionAsync(ct);
 021        var result = await action();
 022        await tx.CommitAsync(ct);
 023        return result;
 024    }
 25
 26    public async Task ExecuteInTransactionAsync(Func<Task> action, CancellationToken ct = default)
 027    {
 028        await using var tx = await Database.BeginTransactionAsync(ct);
 029        await action();
 030        await tx.CommitAsync(ct);
 031    }
 32
 33    protected override void OnModelCreating(ModelBuilder modelBuilder)
 134    {
 135        base.OnModelCreating(modelBuilder);
 36
 137        modelBuilder.Entity<AssetInteraction>(entity =>
 138        {
 139            entity.ToTable(t => t.HasComment(
 140                    "Per-user state for one asset. Never shared between users: two people looking at "
 141                    + "the same file each get their own row. Drives flashback weighting — hidden "
 142                    + "excludes, starred always includes and sorts first, liked raises DisplayWeight."
 143                ));
 144            entity.HasIndex(i => new { i.UserId, i.AssetId }).IsUnique();
 145            entity.HasQueryFilter(i => !i.Asset.IsSoftDeleted);
 146        });
 47
 148        modelBuilder.Entity<Burst>(entity =>
 149        {
 150            entity.ToTable(t => t.HasComment(
 151                    "Groups a rapid-fire sequence so a flashback shows one memory rather than nine "
 152                    + "near-identical frames. The table and schema exist; burst DETECTION is not yet "
 153                    + "implemented in the Worker, so in a running system this table is empty."
 154                ));
 155            entity.Property(b => b.PrimaryAssetId).HasComment(
 156                "The frame chosen to represent the burst. Restrict on delete: losing the cover "
 157                + "would leave a group that cannot be rendered.");
 158
 159            // Primary Asset reference (1:1 style lookup)
 160            entity.HasOne(b => b.PrimaryAsset)
 161                .WithMany()
 162                .HasForeignKey(b => b.PrimaryAssetId)
 163                .OnDelete(DeleteBehavior.Restrict);
 164            entity.HasQueryFilter(b => !b.PrimaryAsset.IsSoftDeleted);
 165        });
 66
 167        modelBuilder.Entity<MediaAsset>(entity =>
 168        {
 169            entity.ToTable(t => t.HasComment(
 170                    "One row per media file, scoped to a storage config. Written ONLY by the Worker — "
 171                    + "the API never writes media data."
 172                ));
 173            entity.Property(m => m.ContentHash).HasComment(
 174                "XXHash64 of the file contents. Dedup is deliberately CONFIG-SCOPED: the same "
 175                + "bytes under two storage configs are two assets, which is what makes multi-user "
 176                + "work. A global unique index on this column alone would break that and must "
 177                + "never be added.");
 178            entity.Property(m => m.FilePath).HasComment(
 179                "Path relative to the storage config root. Not to be confused with "
 180                + "ProxyFiles.ProxyPath, which is relative to the proxy root.");
 181            entity.Property(m => m.Month).HasComment(
 182                "Stored apart from DateCaptured so \"On This Day\" is an index lookup on "
 183                + "(Month, Day) rather than an EXTRACT() scan over every row.");
 184            entity.Property(m => m.Day).HasComment(
 185                "See Month — the pair backs IX_MediaAsset_Flashback.");
 186            entity.Property(m => m.PairedAssetId).HasComment(
 187                "Live Photo pairing: the HEIC parent points at its MOV child. SetNull on delete "
 188                + "so losing one half leaves the other a usable standalone asset.");
 189            entity.Property(m => m.IsSoftDeleted).HasComment(
 190                "Set by reconciliation when a file is gone from the NAS. Soft rather than hard so "
 191                + "the user's interactions survive a temporarily unmounted share.");
 192
 193            // Composite Index for "On This Day" (Optimized for Flashbacks)
 194            entity.HasIndex(m => new { m.Month, m.Day }).HasDatabaseName("IX_MediaAsset_Flashback");
 195
 196            // Index for Move Tracking (scoped to storage config for efficient idempotency lookups).
 197            // ⚠️ Deliberately NOT unique today, and that is a known defect (#159): nothing enforces
 198            // the "one active asset per (config, hash)" invariant IdempotencyCheckMiddleware
 199            // assumes, so concurrent consumers can create silent duplicates. The fix is a
 1100            // config-scoped PARTIAL unique index filtered to active rows — not a plain unique
 1101            // index, which would collide with soft-deleted history.
 1102            entity.HasIndex(m => new { m.StorageConfigId, m.ContentHash });
 1103
 1104            // Unique Constraint: One file path per storage config. Unlike the hash index above
 1105            // this one IS enforced, so re-ingesting the same path cannot produce a second row
 1106            // even while #159 is open.
 1107            entity.HasIndex(m => new { m.StorageConfigId, m.FilePath }).IsUnique();
 1108
 1109            // Self-Reference (Paired Asset)
 1110            entity.HasOne(m => m.PairedAsset)
 1111                  .WithMany()
 1112                  .HasForeignKey(m => m.PairedAssetId)
 1113                  .OnDelete(DeleteBehavior.SetNull);
 1114
 1115            // N:1 with Burst
 1116            entity.HasOne(m => m.Burst)
 1117                  .WithMany(b => b.Assets)
 1118                  .HasForeignKey(m => m.BurstId)
 1119                  .OnDelete(DeleteBehavior.SetNull); // Keeping the photo if the burst is dissolved
 1120
 1121            // 1:N with ProxyFiles
 1122            entity.HasMany(m => m.ProxyFiles)
 1123                  .WithOne(p => p.Asset)
 1124                  .HasForeignKey(p => p.AssetId)
 1125                  .OnDelete(DeleteBehavior.Cascade);
 1126
 1127            // 1:N with Interactions
 1128            entity.HasMany(m => m.Interactions)
 1129                  .WithOne(i => i.Asset)
 1130                  .HasForeignKey(i => i.AssetId);
 1131
 1132            // Partial index covering only active (non-deleted) assets
 1133            entity.HasIndex(m => m.IsSoftDeleted)
 1134                  .HasFilter(@"""IsSoftDeleted"" = false")
 1135                  .HasDatabaseName("IX_MediaAssets_Active");
 1136
 1137            // Global Query Filter: Hide soft-deleted files by default
 1138            entity.HasQueryFilter(m => !m.IsSoftDeleted);
 1139
 1140            // Enum Conversion
 1141            entity.Property(m => m.MediaType).HasConversion<string>();
 1142        });
 143
 1144        modelBuilder.Entity<Metadata>(entity =>
 1145        {
 1146            entity.ToTable(t => t.HasComment(
 1147                    "EXIF data, 1:1 with MediaAsset and keyed by it — there is no separate Id. "
 1148                    + "Populated by ExifExtractionMiddleware; every field is best-effort, because a "
 1149                    + "file may carry no EXIF at all."
 1150                ));
 1151            entity.Property(m => m.OrientationDegrees).HasComment(
 1152                "Rotation needed to display the image upright, already normalised from the raw "
 1153                + "EXIF orientation flag so consumers never interpret the flag themselves.");
 1154            entity.HasKey(m => m.AssetId);
 1155
 1156            // 1:1 with MediaAsset
 1157            entity.HasOne(m => m.Asset)
 1158                  .WithOne(a => a.Metadata)
 1159                  .HasForeignKey<Metadata>(m => m.AssetId)
 1160                  .OnDelete(DeleteBehavior.Cascade);
 1161            entity.HasQueryFilter(e => !e.Asset.IsSoftDeleted);
 1162        });
 163
 1164        modelBuilder.Entity<ProxyFile>(entity =>
 1165        {
 1166            entity.ToTable(t => t.HasComment(
 1167                    "One row per generated artefact on the local SSD. An asset has several. Written "
 1168                    + "only by ProxyStagingWriter, at the moment the file reaches its final name."
 1169                ));
 1170            entity.Property(p => p.ProxyPath).HasComment(
 1171                "Path relative to the proxy root, sharded by CONTENT rather than by ingestion "
 1172                + "attempt so a retry overwrites its own debris instead of stranding it. "
 1173                + "IProxyDirectoryStrategy is the single place that rule lives.");
 1174            entity.Property(p => p.ProxyType).HasComment(
 1175                "Thumbnail, WebVideo, FullPreview or BlurHash, persisted as text. Note BlurHash "
 1176                + "is an ordinary proxy ROW whose content is a file — not a string column on the "
 1177                + "asset.");
 1178
 1179            entity.HasIndex(p => p.AssetId);
 1180            entity.Property(p => p.ProxyType).HasConversion<string>();
 1181            entity.HasQueryFilter(e => !e.Asset.IsSoftDeleted);
 1182        });
 183
 1184        modelBuilder.Entity<RefreshToken>(entity =>
 1185        {
 1186            entity.ToTable(t => t.HasComment(
 1187                    "Issued and revoked by the API; expired rows are swept by the Worker's "
 1188                    + "TokenCleanupService. One of the few tables both services touch."
 1189                ));
 1190            entity.Property(r => r.TokenHash).HasComment(
 1191                "Hash, never the token itself — a database disclosure must not hand out usable "
 1192                + "sessions.");
 1193
 1194            entity.HasIndex(r => r.TokenHash).IsUnique();
 1195            entity.HasIndex(r => new { r.UserId, r.ExpiresAt });
 1196
 1197            entity.HasOne(r => r.User)
 1198                  .WithMany(u => u.RefreshTokens)
 1199                  .HasForeignKey(r => r.UserId)
 1200                  .OnDelete(DeleteBehavior.Cascade);
 1201        });
 202
 1203        modelBuilder.Entity<Invite>(entity =>
 1204        {
 1205            entity.ToTable(t => t.HasComment(
 1206                    "Admin-issued registration tokens. Registration requires one; the first user "
 1207                    + "(bootstrap admin) is exempt. Redemption is a read-then-write race, which is "
 1208                    + "why this is the only table carrying a concurrency token."
 1209                ));
 1210            entity.Property(i => i.TokenHash).HasComment(
 1211                "Hash, never the token itself. 44 chars — base64 of a 32-byte value.");
 1212
 1213            entity.HasIndex(i => i.TokenHash).IsUnique();
 1214            entity.Property(i => i.TokenHash).HasMaxLength(44);
 1215
 1216            // xmin is a Postgres system column incremented on every UPDATE.
 1217            // Npgsql recognises the "xmin" column name and excludes it from migrations.
 1218            entity.Property<uint>("xmin")
 1219                  .HasColumnName("xmin")
 1220                  .ValueGeneratedOnAddOrUpdate()
 1221                  .IsConcurrencyToken();
 1222
 1223            entity.HasOne(i => i.CreatedBy)
 1224                  .WithMany()
 1225                  .HasForeignKey(i => i.CreatedByUserId)
 1226                  .OnDelete(DeleteBehavior.SetNull);
 1227
 1228            entity.HasOne(i => i.UsedBy)
 1229                  .WithMany()
 1230                  .HasForeignKey(i => i.UsedByUserId)
 1231                  .OnDelete(DeleteBehavior.SetNull);
 1232        });
 233
 1234        modelBuilder.Entity<User>(entity =>
 1235        {
 1236            entity.ToTable(t => t.HasComment(
 1237                    "Owns storage configs and interactions. All identity writes happen in the API; "
 1238                    + "the Worker only reads."
 1239                ));
 1240            entity.Property(u => u.PasswordHash).HasComment(
 1241                "Argon2id. The parameters live with the hasher, not here, so rotating them does "
 1242                + "not require a migration.");
 1243            entity.Property(u => u.MustChangePassword).HasComment(
 1244                "Set by an admin reset. MustChangePasswordMiddleware then 403s every route except "
 1245                + "the handful needed to change it.");
 1246
 1247            entity.HasIndex(u => u.Username).IsUnique().HasDatabaseName(UserIndexNames.UsernameUnique);
 1248            entity.HasIndex(u => u.Email).IsUnique().HasDatabaseName(UserIndexNames.EmailUnique);
 1249
 1250            // 1:N with StorageConfig
 1251            entity.HasMany(u => u.StorageConfigs)
 1252                  .WithOne(s => s.User)
 1253                  .HasForeignKey(s => s.UserId);
 1254
 1255            // 1:N with Interactions
 1256            entity.HasMany(u => u.Interactions)
 1257                  .WithOne(i => i.User)
 1258                  .HasForeignKey(i => i.UserId);
 1259        });
 260
 1261        modelBuilder.Entity<UserStorageConfig>(entity =>
 1262        {
 1263            entity.ToTable(t => t.HasComment(
 1264                    "A NAS root assigned to one user. This is the boundary that scopes assets to a "
 1265                    + "user and lets several Workers each monitor a separate config."
 1266                ));
 1267            entity.Property(s => s.RootPath).HasComment(
 1268                "GLOBALLY unique, not per user: a path cannot be assigned to two configs across "
 1269                + "all users. Two users indexing the same directory would duplicate every asset "
 1270                + "and let one user's config removal cascade away the other's rows.");
 1271
 1272            entity.HasIndex(s => s.UserId);
 1273            entity.HasIndex(s => s.RootPath).IsUnique();
 1274
 1275            // 1:N with MediaAsset
 1276            entity.HasMany(s => s.Assets)
 1277                  .WithOne(a => a.StorageConfig)
 1278                  .HasForeignKey(a => a.StorageConfigId);
 1279        });
 1280    }
 281}