From 66da0718a1d277c299001f576ca68869da83065c Mon Sep 17 00:00:00 2001 From: Codex-Symphony Date: Fri, 31 Jul 2026 17:28:05 +0300 Subject: [PATCH 1/2] [DEV-20260731-002-API] Add SQLite persistence Closes #2 --- .../DemoApplication.Api.csproj | 4 + src/DemoApplication.Api/Program.cs | 1 + .../appsettings.Development.json | 5 +- src/DemoApplication.Api/appsettings.json | 5 +- .../DemoApplication.Infrastructure.csproj | 5 + .../DemoApplicationDbContext.cs | 57 +++++++++ ...60731142231_InitialPersistence.Designer.cs | 108 ++++++++++++++++++ .../20260731142231_InitialPersistence.cs | 95 +++++++++++++++ .../DemoApplicationDbContextModelSnapshot.cs | 105 +++++++++++++++++ .../PersistenceIndexRecords.cs | 30 +++++ .../PersistenceOptions.cs | 8 ++ .../PersistenceRole.cs | 14 +++ .../PersistenceServiceCollectionExtensions.cs | 46 ++++++++ .../StartupCheck.cs | 23 +++- 14 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 src/DemoApplication.Infrastructure/DemoApplicationDbContext.cs create mode 100644 src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.Designer.cs create mode 100644 src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.cs create mode 100644 src/DemoApplication.Infrastructure/Persistence/Migrations/DemoApplicationDbContextModelSnapshot.cs create mode 100644 src/DemoApplication.Infrastructure/PersistenceIndexRecords.cs create mode 100644 src/DemoApplication.Infrastructure/PersistenceOptions.cs create mode 100644 src/DemoApplication.Infrastructure/PersistenceRole.cs create mode 100644 src/DemoApplication.Infrastructure/PersistenceServiceCollectionExtensions.cs diff --git a/src/DemoApplication.Api/DemoApplication.Api.csproj b/src/DemoApplication.Api/DemoApplication.Api.csproj index 3d663a6..d9ab760 100644 --- a/src/DemoApplication.Api/DemoApplication.Api.csproj +++ b/src/DemoApplication.Api/DemoApplication.Api.csproj @@ -7,6 +7,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/DemoApplication.Api/Program.cs b/src/DemoApplication.Api/Program.cs index 7790311..d7aca69 100644 --- a/src/DemoApplication.Api/Program.cs +++ b/src/DemoApplication.Api/Program.cs @@ -7,6 +7,7 @@ builder.Services.AddProblemDetails(); builder.Services.AddHealthChecks(); builder.Services.AddControllers(); +builder.Services.AddPersistence(builder.Configuration, builder.Environment.ContentRootPath); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/DemoApplication.Api/appsettings.Development.json b/src/DemoApplication.Api/appsettings.Development.json index 10f68b8..2866af7 100644 --- a/src/DemoApplication.Api/appsettings.Development.json +++ b/src/DemoApplication.Api/appsettings.Development.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Persistence": { + "DatabasePath": "App_Data/demo-application-development.db" + } } diff --git a/src/DemoApplication.Api/appsettings.json b/src/DemoApplication.Api/appsettings.json index 10f68b8..c534a8e 100644 --- a/src/DemoApplication.Api/appsettings.json +++ b/src/DemoApplication.Api/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Persistence": { + "DatabasePath": "App_Data/demo-application.db" + } } diff --git a/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj b/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj index 7aa6826..1aae4d2 100644 --- a/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj +++ b/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj @@ -1,6 +1,11 @@  + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/DemoApplication.Infrastructure/DemoApplicationDbContext.cs b/src/DemoApplication.Infrastructure/DemoApplicationDbContext.cs new file mode 100644 index 0000000..06c2a22 --- /dev/null +++ b/src/DemoApplication.Infrastructure/DemoApplicationDbContext.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; + +namespace DemoApplication.Infrastructure; + +/// Owns the SQLite persistence model and its deterministic baseline seed data. +public sealed class DemoApplicationDbContext : DbContext +{ + /// Initializes the context with configured provider options. + /// Provider and database options. + public DemoApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + /// Gets the baseline roles. + public DbSet Roles => Set(); + + /// Gets the minimal order index records. + public DbSet Orders => Set(); + + /// Gets the minimal shipment index records. + public DbSet Shipments => Set(); + + /// Configures table names, indexes, keys, and deterministic seed records. + /// Entity model builder supplied by Entity Framework Core. + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Keep the schema foundation independent from future business workflow entities. + modelBuilder.Entity(entity => + { + entity.ToTable("Roles"); + entity.HasKey(role => role.Id); + entity.Property(role => role.Name).IsRequired().HasMaxLength(64); + entity.Property(role => role.NormalizedName).IsRequired().HasMaxLength(64); + entity.HasIndex(role => role.NormalizedName).IsUnique(); + entity.HasData( + new PersistenceRole { Id = new Guid("2e6bd8e6-1a43-4ec2-a2ef-6e9e2ef7f44c"), Name = "Administrator", NormalizedName = "ADMINISTRATOR" }, + new PersistenceRole { Id = new Guid("d9aa8d4e-969c-4ea8-9b11-1c3f4d3b35f2"), Name = "User", NormalizedName = "USER" }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Orders"); + entity.HasKey(order => order.Id); + entity.Property(order => order.Status).IsRequired().HasMaxLength(32); + entity.HasIndex(order => new { order.Status, order.CreatedUtc }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Shipments"); + entity.HasKey(shipment => shipment.Id); + entity.Property(shipment => shipment.Status).IsRequired().HasMaxLength(32); + entity.HasIndex(shipment => new { shipment.OrderId, shipment.Status }); + }); + } +} diff --git a/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.Designer.cs b/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.Designer.cs new file mode 100644 index 0000000..0632c8b --- /dev/null +++ b/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.Designer.cs @@ -0,0 +1,108 @@ +// +using System; +using DemoApplication.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DemoApplication.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(DemoApplicationDbContext))] + [Migration("20260731142231_InitialPersistence")] + partial class InitialPersistence + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("DemoApplication.Infrastructure.OrderIndexRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedUtc"); + + b.ToTable("Orders", (string)null); + }); + + modelBuilder.Entity("DemoApplication.Infrastructure.PersistenceRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = new Guid("2e6bd8e6-1a43-4ec2-a2ef-6e9e2ef7f44c"), + Name = "Administrator", + NormalizedName = "ADMINISTRATOR" + }, + new + { + Id = new Guid("d9aa8d4e-969c-4ea8-9b11-1c3f4d3b35f2"), + Name = "User", + NormalizedName = "USER" + }); + }); + + modelBuilder.Entity("DemoApplication.Infrastructure.ShipmentIndexRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrderId", "Status"); + + b.ToTable("Shipments", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.cs b/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.cs new file mode 100644 index 0000000..d2bfc82 --- /dev/null +++ b/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace DemoApplication.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialPersistence : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Orders", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 32, nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 64, nullable: false), + NormalizedName = table.Column(type: "TEXT", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Shipments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OrderId = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 32, nullable: false), + UpdatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Shipments", x => x.Id); + }); + + migrationBuilder.InsertData( + table: "Roles", + columns: new[] { "Id", "Name", "NormalizedName" }, + values: new object[,] + { + { new Guid("2e6bd8e6-1a43-4ec2-a2ef-6e9e2ef7f44c"), "Administrator", "ADMINISTRATOR" }, + { new Guid("d9aa8d4e-969c-4ea8-9b11-1c3f4d3b35f2"), "User", "USER" } + }); + + migrationBuilder.CreateIndex( + name: "IX_Orders_Status_CreatedUtc", + table: "Orders", + columns: new[] { "Status", "CreatedUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_Roles_NormalizedName", + table: "Roles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Shipments_OrderId_Status", + table: "Shipments", + columns: new[] { "OrderId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Orders"); + + migrationBuilder.DropTable( + name: "Roles"); + + migrationBuilder.DropTable( + name: "Shipments"); + } + } +} diff --git a/src/DemoApplication.Infrastructure/Persistence/Migrations/DemoApplicationDbContextModelSnapshot.cs b/src/DemoApplication.Infrastructure/Persistence/Migrations/DemoApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..ae50664 --- /dev/null +++ b/src/DemoApplication.Infrastructure/Persistence/Migrations/DemoApplicationDbContextModelSnapshot.cs @@ -0,0 +1,105 @@ +// +using System; +using DemoApplication.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DemoApplication.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(DemoApplicationDbContext))] + partial class DemoApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("DemoApplication.Infrastructure.OrderIndexRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedUtc"); + + b.ToTable("Orders", (string)null); + }); + + modelBuilder.Entity("DemoApplication.Infrastructure.PersistenceRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = new Guid("2e6bd8e6-1a43-4ec2-a2ef-6e9e2ef7f44c"), + Name = "Administrator", + NormalizedName = "ADMINISTRATOR" + }, + new + { + Id = new Guid("d9aa8d4e-969c-4ea8-9b11-1c3f4d3b35f2"), + Name = "User", + NormalizedName = "USER" + }); + }); + + modelBuilder.Entity("DemoApplication.Infrastructure.ShipmentIndexRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrderId", "Status"); + + b.ToTable("Shipments", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DemoApplication.Infrastructure/PersistenceIndexRecords.cs b/src/DemoApplication.Infrastructure/PersistenceIndexRecords.cs new file mode 100644 index 0000000..0b0244d --- /dev/null +++ b/src/DemoApplication.Infrastructure/PersistenceIndexRecords.cs @@ -0,0 +1,30 @@ +namespace DemoApplication.Infrastructure; + +/// Provides the minimum order shape required by persistence query indexes. +public sealed class OrderIndexRecord +{ + /// Gets or sets the order identifier. + public Guid Id { get; set; } + + /// Gets or sets the order status used by indexed queries. + public string Status { get; set; } = string.Empty; + + /// Gets or sets the creation timestamp used by indexed queries. + public DateTime CreatedUtc { get; set; } +} + +/// Provides the minimum shipment shape required by persistence query indexes. +public sealed class ShipmentIndexRecord +{ + /// Gets or sets the shipment identifier. + public Guid Id { get; set; } + + /// Gets or sets the related order identifier. + public Guid OrderId { get; set; } + + /// Gets or sets the shipment status used by indexed queries. + public string Status { get; set; } = string.Empty; + + /// Gets or sets the update timestamp used by indexed queries. + public DateTime UpdatedUtc { get; set; } +} diff --git a/src/DemoApplication.Infrastructure/PersistenceOptions.cs b/src/DemoApplication.Infrastructure/PersistenceOptions.cs new file mode 100644 index 0000000..9eba7c5 --- /dev/null +++ b/src/DemoApplication.Infrastructure/PersistenceOptions.cs @@ -0,0 +1,8 @@ +namespace DemoApplication.Infrastructure; + +/// Defines configurable storage locations for application persistence. +public sealed class PersistenceOptions +{ + /// Gets or sets the SQLite database path relative to the content root or as an absolute path. + public string DatabasePath { get; set; } = "App_Data/demo-application.db"; +} diff --git a/src/DemoApplication.Infrastructure/PersistenceRole.cs b/src/DemoApplication.Infrastructure/PersistenceRole.cs new file mode 100644 index 0000000..674b9ed --- /dev/null +++ b/src/DemoApplication.Infrastructure/PersistenceRole.cs @@ -0,0 +1,14 @@ +namespace DemoApplication.Infrastructure; + +/// Stores the baseline role names required by the application. +public sealed class PersistenceRole +{ + /// Gets or sets the stable role identifier. + public Guid Id { get; set; } + + /// Gets or sets the display name of the role. + public string Name { get; set; } = string.Empty; + + /// Gets or sets the normalized role name used for case-insensitive lookup. + public string NormalizedName { get; set; } = string.Empty; +} diff --git a/src/DemoApplication.Infrastructure/PersistenceServiceCollectionExtensions.cs b/src/DemoApplication.Infrastructure/PersistenceServiceCollectionExtensions.cs new file mode 100644 index 0000000..b3aa755 --- /dev/null +++ b/src/DemoApplication.Infrastructure/PersistenceServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace DemoApplication.Infrastructure; + +/// Registers the SQLite persistence foundation in the application host. +public static class PersistenceServiceCollectionExtensions +{ + /// Registers the configurable SQLite context factory. + /// Application service collection. + /// Application configuration. + /// Host content root used to resolve relative paths. + /// The same service collection for composition chaining. + public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration, string contentRootPath) + { + // Validate the composition inputs before resolving paths or mutating the service collection. + ArgumentNullException.ThrowIfNull( + argument: configuration, + paramName: nameof(configuration)); + + ArgumentException.ThrowIfNullOrWhiteSpace( + argument: contentRootPath, + paramName: nameof(contentRootPath)); + + // Resolve the configured path once so relative and absolute locations share the same provider setup. + var options = new PersistenceOptions + { + DatabasePath = configuration["Persistence:DatabasePath"] ?? "App_Data/demo-application.db" + }; + var databasePath = Path.IsPathRooted(options.DatabasePath) + ? options.DatabasePath + : Path.Combine(contentRootPath, options.DatabasePath); + var databaseDirectory = Path.GetDirectoryName(databasePath); + + if (!string.IsNullOrWhiteSpace(databaseDirectory)) + { + // Ensure the configured parent exists before SQLite opens the file. + Directory.CreateDirectory(databaseDirectory); + } + + // Register a factory so startup and future request scopes receive independently owned contexts. + services.AddDbContextFactory(dbContextOptions => dbContextOptions.UseSqlite($"Data Source={databasePath}")); + return services; + } +} diff --git a/src/DemoApplication.Infrastructure/StartupCheck.cs b/src/DemoApplication.Infrastructure/StartupCheck.cs index afd7866..d10a2b2 100644 --- a/src/DemoApplication.Infrastructure/StartupCheck.cs +++ b/src/DemoApplication.Infrastructure/StartupCheck.cs @@ -1,13 +1,32 @@ using DemoApplication.Application.Abstractions; +using Microsoft.EntityFrameworkCore; namespace DemoApplication.Infrastructure; /// Provides the baseline startup check for infrastructure wiring. public sealed class StartupCheck : IStartupCheck { + // Holds the factory that owns short-lived contexts for startup migration work. + private readonly IDbContextFactory _dbContextFactory; + + /// Initializes the persistence startup check. + /// Factory for the isolated startup migration context. + public StartupCheck(IDbContextFactory dbContextFactory) + { + ArgumentNullException.ThrowIfNull( + argument: dbContextFactory, + paramName: nameof(dbContextFactory)); + + _dbContextFactory = dbContextFactory; + } + /// Completes successfully when baseline infrastructure is available. - public Task CheckAsync(CancellationToken cancellationToken) + public async Task CheckAsync(CancellationToken cancellationToken) { - return Task.CompletedTask; + // Create a short-lived context so migration state is isolated from future request work. + await using DemoApplicationDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + // Apply pending migrations before the host accepts traffic; seeded roles remain idempotent. + await dbContext.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); } } From 7e4364a8349e2d06f8def1eeb6e82a877b106b91 Mon Sep 17 00:00:00 2001 From: Codex-Symphony Date: Fri, 31 Jul 2026 18:24:13 +0300 Subject: [PATCH 2/2] [DEV-20260731-002-API-REMEDIATION] Resolve SQLite native advisory Refs #2 --- .../DemoApplication.Infrastructure.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj b/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj index 1aae4d2..3b72c8d 100644 --- a/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj +++ b/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj @@ -6,6 +6,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive +