Files

12 KiB

CLAUDE.md - DeepDrftData

Guidance for working in the DeepDrftData project (the SQL-side domain logic).

See the root CLAUDE.md for full architecture overview. This file covers what is specific to this project.

One-line purpose

SQL-side domain logic for tracks. EF Core context, configurations, migrations, repository, manager, design-time factory. Consumed by DeepDrftAPI (the dual-database authority) and tests.

Why this project exists

Separating domain logic from hosts so DeepDrftAPI can reuse TrackManager / TrackRepository / DeepDrftContext without embedding them directly in the host. Tests also consume this library.

New SQL-side domain code goes here, not in the host projects.

Layout

DeepDrftData/
├── Data/
│   ├── DeepDrftContext.cs              # EF DbContext
│   ├── DeepDrftContextFactory.cs       # Design-time factory (reads environment/connections.json; Npgsql dummy fallback)
│   └── Configurations/
│       └── TrackConfiguration.cs       # EF fluent configuration for TrackEntity
├── Migrations/                         # EF-generated migrations (namespace DeepDrftData.Migrations)
├── Repositories/
│   └── TrackRepository.cs              # Data access layer
├── TrackManager.cs                     # Service orchestrator (public interface: ITrackService)
└── DeepDrftData.csproj

EF DbContext and configuration

DeepDrftContext targets PostgreSQL (Npgsql), connection string from environment/connections.json (loaded at runtime via CredentialTools.ResolvePathOrThrow("connections", ...) in DeepDrftAPI/Program.cs, key ConnectionStrings:DefaultConnection). The design-time factory (DeepDrftContextFactory) reads the same environment/connections.json when present and falls back to a Npgsql dummy connection string (Host=localhost;Database=deepdrft-design-time;Username=dummy) for CI or environments without the file, so dotnet ef commands work without a live database.

TrackConfiguration uses EF fluent API:

  • Table name: track (singular)
  • Columns: snake_case (entry_key, track_name, artist, album, genre, release_date, image_path)
  • EntryKey: required, max 100 (the FileDatabase vault entry id)
  • TrackName, Artist: required, max 200
  • Album, Genre: optional, max 200 / 100
  • ReleaseDate: optional DateOnly
  • ImagePath: optional, max 500 (currently a free-form URL string; points to images vault in future)
  • DurationSeconds: optional double? (nullable; populated at upload from vault audio; backfillable via POST api/track/duration/backfill; used for aggregate mix-runtime queries). Column: duration_seconds. Migration: 20260618155002_AddTrackDuration.

Service → Repository → DbContext shape

  • Service (TrackManager, implements ITrackService): Public contract. Takes TrackRepository, catches exceptions at service boundary, returns ResultContainer<T> with DTO results.
  • Repository (TrackRepository): Internal data access. Queries the DbContext. Throws on error (service catches).
  • DbContext (DeepDrftContext): EF Core. Directly accessed by repository, never by service (pattern isolation).

Notable repository / service methods beyond the standard CRUD:

  • TrackRepository.GetHomeStatsAsync / ITrackService.GetHomeStats: Returns HomeStatsDto — cut track count, per-ReleaseType cut release counts (zero-suppressed), mix release count, total mix runtime seconds (null durations counted as 0; tracks under a soft-deleted release excluded). Used by StatsController.
  • TrackRepository.UpdateDurationAsync / ITrackService.UpdateDuration: Null-guarded duration write — skips rows where DurationSeconds is already set. Used by the one-time backfill (POST api/track/duration/backfill).
  • TrackRepository.SetDurationAsync / ITrackService.SetDuration: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (POST api/track/{id:long}/replace-audio) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
  • ITrackService.FindOrCreateRelease / TrackManager.FindOrCreateRelease: Finds the release row matching (title, artist) or creates one if none exists. Returns ResultContainer<(ReleaseDto Release, bool WasCreated)> — the WasCreated flag lets the upload CREATE path distinguish a freshly minted release from one returned because a concurrent upload won the insert race (the latter is treated as a duplicate and rejected with 409, not silently attached). ITrackService.GetReleaseByTitleAndArtist is the read-only counterpart used for the upload pre-flight check and the ATTACH-path validation.

Phase 16 — anonymous telemetry domain (EventRepository / EventManager)

EventRepository and EventManager (with IEventService boundary) are the SQL-side domain for anonymous play/share telemetry (Phase 16 waves 16.1 + 16.3). Unlike TrackRepository, these entities have no soft-delete lifecycle and are not BaseEntity/IEntityEventRepository is a plain context-backed repository against the same scoped DeepDrftContext.

  • EventRepository (Repositories/EventRepository.cs): append-only writes to the play_event and share_event tables; incremental-on-write bump of the play_counter rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track EntryKey, the repository joins track→release and stamps the release_id on the row. Also owns the three distinct-listener aggregation queries added in wave 16.3: CountDistinctListenersAsync() (site-wide), CountDistinctListenersForTrackAsync(trackEntryKey), CountDistinctListenersForReleaseAsync(releaseId) — each excludes null anon_id rows.
  • EventManager / IEventService (EventManager.cs): RecordPlay(trackEntryKey, bucket, anonId, ct) and RecordShare(targetType, targetKey, channel, anonId, ct) return NetBlocks Result. Wave 16.3 added three distinct-count members returning ResultContainer<int>: GetDistinctListenerCount(), GetDistinctListenerCountForTrack(trackEntryKey), GetDistinctListenerCountForRelease(releaseId). Registered scoped in DeepDrftAPI/Program.cs. Migration: 20260619155610_AddPlayShareTelemetry (authored; not yet applied — Daniel-gated). The anon_id columns and covering indexes on play_event/share_event are part of this migration — no additional migration was needed for 16.3.

Example:

// TrackManager.GetPaged (public via ITrackService)
// Returns PagedResult<TrackDto> — the repository outputs entities, the service outputs DTOs
public async Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(
    int pageNumber = 1,
    int pageSize = 20,
    string? sortColumn = null,
    bool sortDescending = false,
    CancellationToken cancellationToken = default)
{
    try
    {
        var parameters = new PagingParameters<TrackEntity>
        {
            Page = pageNumber,
            PageSize = pageSize,
            OrderBy = GetOrderExpression(sortColumn),  // Maps string to LINQ expression
            IsDescending = sortDescending
        };
        var result = await _repository.GetPagedAsync(parameters, cancellationToken);
        // Convert to DTO before returning
        var dtoResult = new PagedResult<TrackDto>(
            result.Items.Select(TrackConverter.Convert).ToList(),
            result.TotalCount,
            result.PageNumber,
            result.PageSize);
        return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoResult);
    }
    catch (Exception e)
    {
        return ResultContainer<PagedResult<TrackDto>>.CreateFailResult(e.Message);
    }
}

Pagination convention

PagingParameters<T> holds:

  • Page (1-based, default 1)
  • PageSize (default 20, capped at 100)
  • OrderBy: Expression<Func<T, object>>? (LINQ expression for sorting)
  • IsDescending

TrackManager.GetPaged (the public service method) maps a string sortColumn (from the API query) to an expression via a switch:

private static Expression<Func<TrackEntity, object>> GetOrderExpression(string? sortColumn)
    => sortColumn switch
    {
        "TrackName" => e => e.TrackName,
        "Artist" => e => e.Artist,
        "Album" => e => e.Album ?? "",  // Nulls sort to end
        "Genre" => e => e.Genre ?? "",
        "ReleaseDate" => e => e.ReleaseDate ?? DateOnly.MaxValue,
        _ => e => e.Id  // Default to ID
    };

Add new sort columns by extending this switch and the corresponding column names in the API.

EF Migration commands

Run from the solution root:

# Add a migration
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI

# Apply to database
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI

The design-time factory means you can also run dotnet ef ... --project DeepDrftData standalone for local development (it doesn't need the startup project).

Migrations namespace

Migrations live in the DeepDrftData.Migrations namespace. Migration files are auto-generated and rarely edited by hand.

Connection string

  • DeepDrftAPI: environment/connections.jsonConnectionStrings:DefaultConnection
  • Always PostgreSQL (Npgsql) — both production and local development.

The design-time factory reads environment/connections.json when present; falls back to a Npgsql dummy for CI.

Service registration

In DeepDrftAPI/Program.cs:

services.AddDbContext<DeepDrftContext>(options =>
    options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<TrackRepository>();
services.AddScoped<TrackManager>();
services.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());

This pattern allows callers to depend on ITrackService (the public interface) without knowing about TrackManager (the implementation).

Important patterns

  • Required properties: EntryKey, TrackName, Artist are required strings. This compile-time guarantee prevents half-built entities from reaching the database.
  • Optional properties: Album?, Genre?, ReleaseDate?, ImagePath? are nullable. Queries must handle nulls (e.g., Album ?? "" in the sort expression to push nulls to end).
  • Result types: Services return ResultContainer<T> or Result from NetBlocks. No exceptions propagate to callers — the service catches and wraps.
  • Async operations: All database methods are async (GetPagedAsync, GetByIdAsync, etc.). Sync is not available.

Development commands

# Build
dotnet build DeepDrftData

# Add migration (from solution root)
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI

# Apply migration
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI

# Run API (dual-database host consuming this service)
dotnet run --project DeepDrftAPI

What does NOT live here

  • HTTP controllers or middleware (in host projects)
  • Blazor components or rendering logic (in host projects)
  • FileDatabase or binary content code (in DeepDrftContent.Services)
  • Host-specific wiring or configuration (in host Program.cs / appsettings.json)
  • Host-internal services like UnifiedTrackService (in host Services/ folders)

When working with this project, focus on the data layer (repository, manager, EF configuration) and ensure all new SQL logic is testable and reusable by multiple consumers (Content API, Public API, CLI) without host-specific dependencies.