diff --git a/CoreSync b/CoreSync index 1b31860..a3a4734 160000 --- a/CoreSync +++ b/CoreSync @@ -1 +1 @@ -Subproject commit 1b31860621022519822874b953dd5952414dd38f +Subproject commit a3a4734fbf72fe5b0babfe2b1171cd42811cd0c9 diff --git a/src/Data/DataStore.cs b/src/Data/DataStore.cs index 9e69049..7dc56e4 100644 --- a/src/Data/DataStore.cs +++ b/src/Data/DataStore.cs @@ -59,11 +59,29 @@ public class SqliteDataStore : DataStore public class SqlServerDataStore : DataStore { + /// + /// The default number of days SQL Server Change Tracking keeps change history for. + /// + /// + /// A client that stays offline longer than this window cannot resume an incremental sync and has + /// to be reinitialized from a fresh snapshot, so the window has to comfortably cover how long a + /// device is expected to go without connectivity. Longer retention grows the change tracking side + /// tables, which is the cost being traded against here. + /// + public const int DefaultChangeRetentionDays = 30; + public required string ConnectionString { get; set; } public string GetResolvedConnectionString() => ResolveConnectionString(ConnectionString); public SqlServerDataStoreTrackingMode TrackingMode { get; set; } + + /// + /// How many days of change history to retain, applied when + /// is . + /// Ignored for the trigger-based tracking mode, which keeps its own journal. + /// + public int ChangeRetentionDays { get; set; } = DefaultChangeRetentionDays; } public class PostgreSqlDataStore : DataStore diff --git a/src/Server/Data/Migrations/20260826094048_AddSqlServerDataStoreChangeRetentionDays.Designer.cs b/src/Server/Data/Migrations/20260826094048_AddSqlServerDataStoreChangeRetentionDays.Designer.cs new file mode 100644 index 0000000..c3a2f8f --- /dev/null +++ b/src/Server/Data/Migrations/20260826094048_AddSqlServerDataStoreChangeRetentionDays.Designer.cs @@ -0,0 +1,990 @@ +// +using System; +using CoreSyncServer.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CoreSyncServer.Data.Migrations +{ + [DbContext(typeof(CoreSyncServerDbContext))] + [Migration("20260826094048_AddSqlServerDataStoreChangeRetentionDays")] + partial class AddSqlServerDataStoreChangeRetentionDays + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CoreSyncServer.Data.Agent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("LastSeen") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApiKey") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Agents"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + + b.HasData( + new + { + Id = "00000000-0000-0000-0000-000000000001", + AccessFailedCount = 0, + ConcurrencyStamp = "00000000-0000-0000-0000-000000000001", + Email = "admin@localhost", + EmailConfirmed = true, + LockoutEnabled = false, + NormalizedEmail = "ADMIN@LOCALHOST", + NormalizedUserName = "ADMIN", + PasswordHash = "AQAAAAIAAYagAAAAEG1yor+ewRplvj33lrT+XzGAg5S0+b8567EtIg7WbPLQwBO1E4xGeSXFO7AwLnylXg==", + PhoneNumberConfirmed = false, + SecurityStamp = "00000000-0000-0000-0000-000000000001", + TwoFactorEnabled = false, + UserName = "admin" + }); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DataStore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsMonitorEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AgentId"); + + b.HasIndex("ProjectId"); + + b.ToTable("DataStores"); + + b.HasDiscriminator("Type"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DataStoreConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DataStoreId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DataStoreId"); + + b.ToTable("DataStoreConfigurations"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DataStoreTableConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CustomSnapshotQuery") + .HasColumnType("text"); + + b.Property("DataStoreConfigurationId") + .HasColumnType("integer"); + + b.Property("ForceReloadInsertedRecords") + .HasColumnType("boolean"); + + b.Property("IdentityInsert") + .HasColumnType("integer"); + + b.Property("InError") + .HasColumnType("boolean"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Schema") + .HasColumnType("text"); + + b.Property("SelectIncrementalQuery") + .HasColumnType("text"); + + b.Property("SkipColumns") + .HasColumnType("text"); + + b.Property("SkipColumnsOnInsertOrUpdate") + .HasColumnType("text"); + + b.Property("SkipInitialSnapshot") + .HasColumnType("boolean"); + + b.Property("Sort") + .HasColumnType("integer"); + + b.Property("SyncMode") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DataStoreConfigurationId"); + + b.ToTable("DataStoreTableConfigurations"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DiagnosticItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DataStoreConfigurationId") + .HasColumnType("integer"); + + b.Property("DataStoreId") + .HasColumnType("integer"); + + b.Property("EndpointId") + .HasColumnType("uuid"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("SyncSessionId") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DataStoreConfigurationId"); + + b.HasIndex("DataStoreId"); + + b.HasIndex("EndpointId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SyncSessionId"); + + b.ToTable("DiagnosticItems"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.EndPointAuthentication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("EndPointAuthentications"); + + b.HasDiscriminator("Type"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("CoreSyncServer.Data.Endpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthenticationId") + .HasColumnType("integer"); + + b.Property("DataStoreConfigurationId") + .HasColumnType("integer"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AuthenticationId"); + + b.HasIndex("DataStoreConfigurationId"); + + b.ToTable("Endpoints"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.SyncSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DataStoreId") + .HasColumnType("integer"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DataStoreId"); + + b.ToTable("SyncSessions"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.SyncSessionTrace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("SyncSessionId") + .HasColumnType("integer"); + + b.Property("TimeStamp") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SyncSessionId"); + + b.ToTable("SyncSessionTraces"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = "00000000-0000-0000-0000-000000000001", + ConcurrencyStamp = "00000000-0000-0000-0000-000000000001", + Name = "Administrator", + NormalizedName = "ADMINISTRATOR" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("bytea"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + + b.HasData( + new + { + UserId = "00000000-0000-0000-0000-000000000001", + RoleId = "00000000-0000-0000-0000-000000000001" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CoreSyncServer.Data.PostgreSqlDataStore", b => + { + b.HasBaseType("CoreSyncServer.Data.DataStore"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue(2); + }); + + modelBuilder.Entity("CoreSyncServer.Data.SqlServerDataStore", b => + { + b.HasBaseType("CoreSyncServer.Data.DataStore"); + + b.Property("ChangeRetentionDays") + .HasColumnType("integer"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackingMode") + .HasColumnType("integer"); + + b.ToTable("DataStores", t => + { + t.Property("ConnectionString") + .HasColumnName("SqlServerDataStore_ConnectionString"); + }); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("CoreSyncServer.Data.SqliteDataStore", b => + { + b.HasBaseType("CoreSyncServer.Data.DataStore"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("CoreSyncServer.Data.ApiKeyAuthentication", b => + { + b.HasBaseType("CoreSyncServer.Data.EndPointAuthentication"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("CoreSyncServer.Data.BasicAuthentication", b => + { + b.HasBaseType("CoreSyncServer.Data.EndPointAuthentication"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("CoreSyncServer.Data.JwtAuthentication", b => + { + b.HasBaseType("CoreSyncServer.Data.EndPointAuthentication"); + + b.Property("Issuer") + .IsRequired() + .HasColumnType("text"); + + b.Property("JWKSEndpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserIdClaim") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserNameClaim") + .HasColumnType("text"); + + b.HasDiscriminator().HasValue(2); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DataStore", b => + { + b.HasOne("CoreSyncServer.Data.Agent", "Agent") + .WithMany("DataStores") + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CoreSyncServer.Data.Project", "Project") + .WithMany("DataStores") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Agent"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DataStoreConfiguration", b => + { + b.HasOne("CoreSyncServer.Data.DataStore", "DataStore") + .WithMany("Configurations") + .HasForeignKey("DataStoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DataStore"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DataStoreTableConfiguration", b => + { + b.HasOne("CoreSyncServer.Data.DataStoreConfiguration", "DataStoreConfiguration") + .WithMany("TableConfigurations") + .HasForeignKey("DataStoreConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DataStoreConfiguration"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DiagnosticItem", b => + { + b.HasOne("CoreSyncServer.Data.DataStoreConfiguration", "DataStoreConfiguration") + .WithMany("DiagnosticItems") + .HasForeignKey("DataStoreConfigurationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CoreSyncServer.Data.DataStore", "DataStore") + .WithMany("DiagnosticItems") + .HasForeignKey("DataStoreId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CoreSyncServer.Data.Endpoint", "EndPoint") + .WithMany("DiagnosticItems") + .HasForeignKey("EndpointId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CoreSyncServer.Data.Project", "Project") + .WithMany("DiagnosticItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CoreSyncServer.Data.SyncSession", "SyncSession") + .WithMany("DiagnosticItems") + .HasForeignKey("SyncSessionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DataStore"); + + b.Navigation("DataStoreConfiguration"); + + b.Navigation("EndPoint"); + + b.Navigation("Project"); + + b.Navigation("SyncSession"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.Endpoint", b => + { + b.HasOne("CoreSyncServer.Data.EndPointAuthentication", "Authentication") + .WithMany() + .HasForeignKey("AuthenticationId"); + + b.HasOne("CoreSyncServer.Data.DataStoreConfiguration", "DataStoreConfiguration") + .WithMany("Endpoints") + .HasForeignKey("DataStoreConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Authentication"); + + b.Navigation("DataStoreConfiguration"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.SyncSession", b => + { + b.HasOne("CoreSyncServer.Data.DataStore", "DataStore") + .WithMany("SyncSessions") + .HasForeignKey("DataStoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DataStore"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.SyncSessionTrace", b => + { + b.HasOne("CoreSyncServer.Data.SyncSession", "SyncSession") + .WithMany("Traces") + .HasForeignKey("SyncSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SyncSession"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CoreSyncServer.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CoreSyncServer.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("CoreSyncServer.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId"); + + b1.Property("AttestationObject") + .IsRequired(); + + b1.Property("ClientDataJson") + .IsRequired(); + + b1.Property("CreatedAt"); + + b1.Property("IsBackedUp"); + + b1.Property("IsBackupEligible"); + + b1.Property("IsUserVerified"); + + b1.Property("Name"); + + b1.Property("PublicKey") + .IsRequired(); + + b1.Property("SignCount"); + + b1.PrimitiveCollection("Transports"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AspNetUserPasskeys"); + + b1 + .ToJson("Data") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoreSyncServer.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CoreSyncServer.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CoreSyncServer.Data.Agent", b => + { + b.Navigation("DataStores"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DataStore", b => + { + b.Navigation("Configurations"); + + b.Navigation("DiagnosticItems"); + + b.Navigation("SyncSessions"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.DataStoreConfiguration", b => + { + b.Navigation("DiagnosticItems"); + + b.Navigation("Endpoints"); + + b.Navigation("TableConfigurations"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.Endpoint", b => + { + b.Navigation("DiagnosticItems"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.Project", b => + { + b.Navigation("DataStores"); + + b.Navigation("DiagnosticItems"); + }); + + modelBuilder.Entity("CoreSyncServer.Data.SyncSession", b => + { + b.Navigation("DiagnosticItems"); + + b.Navigation("Traces"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Server/Data/Migrations/20260826094048_AddSqlServerDataStoreChangeRetentionDays.cs b/src/Server/Data/Migrations/20260826094048_AddSqlServerDataStoreChangeRetentionDays.cs new file mode 100644 index 0000000..adbbab7 --- /dev/null +++ b/src/Server/Data/Migrations/20260826094048_AddSqlServerDataStoreChangeRetentionDays.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CoreSyncServer.Data.Migrations +{ + /// + public partial class AddSqlServerDataStoreChangeRetentionDays : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ChangeRetentionDays", + table: "DataStores", + type: "integer", + nullable: true); + + // The column is nullable because it belongs to one branch of the TPH hierarchy, but the + // property on SqlServerDataStore is not. Without this backfill every SQL Server data store + // created before this migration would fail to materialize on a null read. + // Type = 1 is DataStoreType.SqlServer, the discriminator value for that branch. + migrationBuilder.Sql( + """ + UPDATE "DataStores" + SET "ChangeRetentionDays" = 30 + WHERE "Type" = 1 AND "ChangeRetentionDays" IS NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ChangeRetentionDays", + table: "DataStores"); + } + } +} diff --git a/src/Server/Data/Migrations/CoreSyncServerDbContextModelSnapshot.cs b/src/Server/Data/Migrations/CoreSyncServerDbContextModelSnapshot.cs index 8639d52..d69344c 100644 --- a/src/Server/Data/Migrations/CoreSyncServerDbContextModelSnapshot.cs +++ b/src/Server/Data/Migrations/CoreSyncServerDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -643,6 +643,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.HasBaseType("CoreSyncServer.Data.DataStore"); + b.Property("ChangeRetentionDays") + .HasColumnType("integer"); + b.Property("ConnectionString") .IsRequired() .HasColumnType("text"); diff --git a/src/Services/Controllers/DataStoresController.cs b/src/Services/Controllers/DataStoresController.cs index 49aab79..3dc0063 100644 --- a/src/Services/Controllers/DataStoresController.cs +++ b/src/Services/Controllers/DataStoresController.cs @@ -58,7 +58,20 @@ public record CreateDataStoreRequest( int ProjectId, string? FilePath, string? ConnectionString, - SqlServerDataStoreTrackingMode? TrackingMode); + SqlServerDataStoreTrackingMode? TrackingMode, + int? ChangeRetentionDays); + + /// + /// The widest change retention the UI accepts. SQL Server itself allows more, but a window this + /// long already means unbounded growth of the change tracking side tables, so a value beyond it + /// is far more likely to be a typo than an intention. + /// + private const int MaxChangeRetentionDays = 365; + + private static string? ValidateChangeRetentionDays(int? days) + => days is null || (days >= 1 && days <= MaxChangeRetentionDays) + ? null + : $"Change retention must be between 1 and {MaxChangeRetentionDays} days."; [HttpPost] public async Task> Create([FromBody] CreateDataStoreRequest request) @@ -66,6 +79,9 @@ public async Task> Create([FromBody] CreateDataStoreR if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new[] { "Name is required." }); + if (ValidateChangeRetentionDays(request.ChangeRetentionDays) is { } retentionError) + return BadRequest(new[] { retentionError }); + var project = await context.Projects.FindAsync(request.ProjectId); if (project is null) return BadRequest(new[] { "Project not found." }); @@ -87,7 +103,8 @@ public async Task> Create([FromBody] CreateDataStoreR ProjectId = request.ProjectId, Type = DataStoreType.SqlServer, ConnectionString = request.ConnectionString ?? "", - TrackingMode = request.TrackingMode ?? SqlServerDataStoreTrackingMode.ChangeTracking + TrackingMode = request.TrackingMode ?? SqlServerDataStoreTrackingMode.ChangeTracking, + ChangeRetentionDays = request.ChangeRetentionDays ?? SqlServerDataStore.DefaultChangeRetentionDays }, DataStoreType.PostgreSQL => new PostgreSqlDataStore { @@ -126,6 +143,7 @@ public record DataStoreDetailDto( string? FilePath, string? ConnectionString, SqlServerDataStoreTrackingMode? TrackingMode, + int? ChangeRetentionDays, int? AgentId, string? AgentName); @@ -151,6 +169,7 @@ public async Task> GetById(int id) (dataStore as SqlServerDataStore)?.ConnectionString ?? (dataStore as PostgreSqlDataStore)?.ConnectionString, (dataStore as SqlServerDataStore)?.TrackingMode, + (dataStore as SqlServerDataStore)?.ChangeRetentionDays, dataStore.AgentId, dataStore.Agent?.Name)); } @@ -161,6 +180,7 @@ public record UpdateDataStoreRequest( string? FilePath, string? ConnectionString, SqlServerDataStoreTrackingMode? TrackingMode, + int? ChangeRetentionDays, int? AgentId); [HttpPut("{id}")] @@ -169,6 +189,9 @@ public async Task Update(int id, [FromBody] UpdateDataStoreRequest if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new[] { "Name is required." }); + if (ValidateChangeRetentionDays(request.ChangeRetentionDays) is { } retentionError) + return BadRequest(new[] { retentionError }); + var dataStore = await context.DataStores.FindAsync(id); if (dataStore is null) return NotFound(); @@ -197,6 +220,8 @@ public async Task Update(int id, [FromBody] UpdateDataStoreRequest sqlServer.ConnectionString = request.ConnectionString.Trim(); if (request.TrackingMode.HasValue) sqlServer.TrackingMode = request.TrackingMode.Value; + if (request.ChangeRetentionDays.HasValue) + sqlServer.ChangeRetentionDays = request.ChangeRetentionDays.Value; break; case PostgreSqlDataStore postgres: if (request.ConnectionString is not null) diff --git a/src/Services/Controllers/ProvisionController.cs b/src/Services/Controllers/ProvisionController.cs index 686ffab..e7e2c6d 100644 --- a/src/Services/Controllers/ProvisionController.cs +++ b/src/Services/Controllers/ProvisionController.cs @@ -20,6 +20,17 @@ public async Task Apply(int dataStoreId) return NoContent(); } + [HttpPost("{dataStoreId}/change-retention")] + public async Task> ApplyChangeRetention(int dataStoreId) + { + var result = await provisionService.ApplyChangeRetentionAsync(dataStoreId); + + if (!result.Success) + return BadRequest(new[] { result.Error }); + + return Ok(result); + } + [HttpPost("{dataStoreId}/remove")] public async Task Remove(int dataStoreId) { diff --git a/src/Services/IProvisionService.cs b/src/Services/IProvisionService.cs index fbd2752..d831c68 100644 --- a/src/Services/IProvisionService.cs +++ b/src/Services/IProvisionService.cs @@ -1,3 +1,5 @@ +using CoreSync.SqlServerCT; + namespace CoreSyncServer.Services; public class ProvisionResult @@ -9,8 +11,51 @@ public class ProvisionResult public static ProvisionResult Fail(string error) => new() { Success = false, Error = error }; } +/// +/// The outcome of applying a data store's change retention setting to its database. +/// +/// +/// Carries the settings read back from the database rather than the ones requested: an operator has to +/// be able to see that the ALTER DATABASE actually took, because a retention that looks saved but +/// was never applied leaves clients aging out of a window nobody believes is still in force. +/// +public class ChangeRetentionResult +{ + public bool Success { get; init; } + public string? Error { get; init; } + + /// + /// The retention actually in effect after the change, as reported by + /// sys.change_tracking_databases. Null when it could not be read back. + /// + public int? EffectiveRetention { get; init; } + + /// + /// The unit is expressed in. + /// + public string? EffectiveRetentionUnit { get; init; } + + public bool? EffectiveAutoCleanup { get; init; } + + public static ChangeRetentionResult Ok(ChangeTrackingDatabaseOptions? options) => new() + { + Success = true, + EffectiveRetention = options?.RetentionPeriod, + EffectiveRetentionUnit = options?.RetentionPeriodUnit.ToString(), + EffectiveAutoCleanup = options?.AutoCleanup + }; + + public static ChangeRetentionResult Fail(string error) => new() { Success = false, Error = error }; +} + public interface IProvisionService { Task ApplyProvisionAsync(int dataStoreId); Task RemoveProvisionAsync(int dataStoreId); + + /// + /// Issues the ALTER DATABASE ... SET CHANGE_TRACKING that brings the database in line with the + /// data store's stored change retention, and reports what the database holds afterwards. + /// + Task ApplyChangeRetentionAsync(int dataStoreId); } diff --git a/src/Services/Implementation/ProvisionService.cs b/src/Services/Implementation/ProvisionService.cs index fa2dd08..76abaac 100644 --- a/src/Services/Implementation/ProvisionService.cs +++ b/src/Services/Implementation/ProvisionService.cs @@ -1,3 +1,4 @@ +using CoreSync.SqlServerCT; using CoreSyncServer.Data; using Microsoft.EntityFrameworkCore; @@ -49,6 +50,33 @@ public async Task RemoveProvisionAsync(int dataStoreId) } } + public async Task ApplyChangeRetentionAsync(int dataStoreId) + { + // Deliberately not requiring any tracked tables: retention is a database-level setting and has + // to be adjustable before, or independently of, provisioning the tables themselves. + var configuration = await BuildMergedConfigurationAsync(dataStoreId); + if (configuration is null) + return ChangeRetentionResult.Fail("Data store not found."); + + if (configuration.DataStore is not SqlServerDataStore { TrackingMode: SqlServerDataStoreTrackingMode.ChangeTracking }) + return ChangeRetentionResult.Fail("Change retention applies only to SQL Server data stores using native change tracking."); + + try + { + if (syncProviderFactory.CreateSyncProvider(configuration) is not SqlServerCTProvider provider) + { + return ChangeRetentionResult.Fail( + "Change retention cannot be applied from the server for an agent-backed data store. Re-provision the data store through its agent instead."); + } + + return ChangeRetentionResult.Ok(await provider.ApplyChangeRetentionAsync()); + } + catch (Exception ex) + { + return ChangeRetentionResult.Fail($"Failed to apply change retention: {ex.Message}"); + } + } + /// /// Builds a virtual DataStoreConfiguration that merges all distinct tracked tables /// across every configuration belonging to the given data store. diff --git a/src/Services/Implementation/SyncProviderFactory.cs b/src/Services/Implementation/SyncProviderFactory.cs index f238797..f619204 100644 --- a/src/Services/Implementation/SyncProviderFactory.cs +++ b/src/Services/Implementation/SyncProviderFactory.cs @@ -93,6 +93,11 @@ private ISyncProvider CreateSqlServerCTProvider(SqlServerDataStore dataStore, Li { var builder = new SqlServerCTSyncConfigurationBuilder(dataStore.GetResolvedConnectionString()); + // Setting this explicitly is what makes provisioning reconcile the retention on a database + // that already has change tracking enabled. Left unset, the stored value would apply only to + // databases being provisioned for the first time and silently do nothing everywhere else. + builder.ChangeRetention(dataStore.ChangeRetentionDays); + foreach (var table in tables) { builder.Table(table.Name, diff --git a/src/Services/Implementation/SyncSessionService.cs b/src/Services/Implementation/SyncSessionService.cs index 4da1777..b0b1722 100644 --- a/src/Services/Implementation/SyncSessionService.cs +++ b/src/Services/Implementation/SyncSessionService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using CoreSyncServer.Data; using Microsoft.EntityFrameworkCore; @@ -47,6 +48,22 @@ await context.SyncSessions .Select(s => new { s.DataStoreId }) .FirstAsync(cancellationToken); + // A failed session has to carry its reason in its own traces. Some failures - an anchor that + // has aged out of the change retention window is the usual one - are raised before the + // per-table trace loop starts, so the session's trace list would otherwise be a single + // "Begin GetChanges" line and Status = Error, saying nothing about what went wrong. The + // message reaches DiagnosticItems either way, but nothing leads there from the session + // detail view, which is where anyone investigating a failed session looks first. + context.SyncSessionTraces.Add(new SyncSessionTrace + { + SyncSessionId = sessionId, + Message = errorMessage, + TimeStamp = DateTime.UtcNow, + TraceLevel = TraceLevel.Error + }); + + await context.SaveChangesAsync(cancellationToken); + await diagnosticService.CreateAsync(new DiagnosticItem { Message = errorMessage, diff --git a/src/WebClient/Pages/DataStores/CreateDataStore.razor b/src/WebClient/Pages/DataStores/CreateDataStore.razor index 8af3271..a6b0f02 100644 --- a/src/WebClient/Pages/DataStores/CreateDataStore.razor +++ b/src/WebClient/Pages/DataStores/CreateDataStore.razor @@ -1,4 +1,4 @@ -@page "/data-store/{TypeName}/new/{ProjectId:int}/{EncodedName}" +@page "/data-store/{TypeName}/new/{ProjectId:int}/{EncodedName}" @attribute [Authorize] @inherits InteractivePage @inject NavigationManager NavigationManager @@ -155,6 +155,16 @@ + + @if (_trackingMode == SqlServerTrackingMode.ChangeTracking) + { +
+ + +

How long change history is kept. A client offline longer than this must reset its local database and re-sync from scratch. Longer retention grows the change tracking side tables.

+
+ } } @@ -214,12 +224,15 @@ private enum DataStoreType { SQLite, SqlServer, PostgreSQL } private enum SqlServerTrackingMode { Triggers, ChangeTracking } + private const int DefaultChangeRetentionDays = 30; + private DataStoreType _type; private string _name = ""; private string _description = ""; private string _filePath = ""; private string _connectionString = ""; private SqlServerTrackingMode _trackingMode = SqlServerTrackingMode.ChangeTracking; + private int _changeRetentionDays = DefaultChangeRetentionDays; private bool _isSaving; private List _errorMessages = []; private string[] _steps = []; @@ -251,6 +264,8 @@ 0 => !string.IsNullOrWhiteSpace(_name), 1 when _type == DataStoreType.SQLite => !string.IsNullOrWhiteSpace(_filePath), 1 => !string.IsNullOrWhiteSpace(_connectionString), + 2 when _type == DataStoreType.SqlServer && _trackingMode == SqlServerTrackingMode.ChangeTracking + => _changeRetentionDays >= 1 && _changeRetentionDays <= 365, _ => true }; @@ -283,7 +298,8 @@ ProjectId, FilePath = _type == DataStoreType.SQLite ? _filePath.Trim() : null, ConnectionString = _type != DataStoreType.SQLite ? _connectionString.Trim() : null, - TrackingMode = _type == DataStoreType.SqlServer ? (int?)_trackingMode : null + TrackingMode = _type == DataStoreType.SqlServer ? (int?)_trackingMode : null, + ChangeRetentionDays = _type == DataStoreType.SqlServer ? (int?)_changeRetentionDays : null }; var response = await Http.PostAsJsonAsync("api/datastores", request); diff --git a/src/WebClient/Pages/DataStores/EditDataStore.razor b/src/WebClient/Pages/DataStores/EditDataStore.razor index 1ad4224..46556e6 100644 --- a/src/WebClient/Pages/DataStores/EditDataStore.razor +++ b/src/WebClient/Pages/DataStores/EditDataStore.razor @@ -1,4 +1,4 @@ -@page "/data-stores/{DataStoreId:int}/edit" +@page "/data-stores/{DataStoreId:int}/edit" @attribute [Authorize] @inherits InteractivePage @implements IAsyncDisposable @@ -133,6 +133,15 @@ else + @if (_editTrackingMode == SqlServerDataStoreTrackingMode.ChangeTracking) + { +
+ + +

How long change history is kept. A client offline longer than this must reset its local database and re-sync from scratch. Longer retention grows the change tracking side tables.

+
+ } break; case DataStoreType.PostgreSQL: @@ -752,12 +761,14 @@ else private string _editConnectionString = ""; private bool _showConnectionString; private SqlServerDataStoreTrackingMode _editTrackingMode; + private int _editChangeRetentionDays = DefaultChangeRetentionDays; private int _editAgentId; private string _savedName = ""; private string _savedDescription = ""; private string _savedFilePath = ""; private string _savedConnectionString = ""; private SqlServerDataStoreTrackingMode _savedTrackingMode; + private int _savedChangeRetentionDays = DefaultChangeRetentionDays; private int _savedAgentId; private List? _agentOptions; private bool _isSavingDetail; @@ -838,8 +849,12 @@ else private enum DataStoreType { SQLite, SqlServer, PostgreSQL } private enum SqlServerDataStoreTrackingMode { Triggers, ChangeTracking } - private record DataStoreDetailDto(int Id, string Name, string? Description, DataStoreType Type, string ProjectName, int ProjectId, bool IsMonitorEnabled, string? FilePath, string? ConnectionString, SqlServerDataStoreTrackingMode? TrackingMode, int? AgentId, string? AgentName); + + private const int DefaultChangeRetentionDays = 30; + + private record DataStoreDetailDto(int Id, string Name, string? Description, DataStoreType Type, string ProjectName, int ProjectId, bool IsMonitorEnabled, string? FilePath, string? ConnectionString, SqlServerDataStoreTrackingMode? TrackingMode, int? ChangeRetentionDays, int? AgentId, string? AgentName); private record AgentOptionDto(int Id, string Name, bool Enabled, DateTime? LastSeen, bool IsConnected); + private record ChangeRetentionResultDto(bool Success, string? Error, int? EffectiveRetention, string? EffectiveRetentionUnit, bool? EffectiveAutoCleanup); private record ConfigurationListDto(int Id, string Name, string? Description, int Version, int TableConfigCount, int EndpointCount, bool InError); private record AuthenticationDto(int Type, string? Username, string? Password, string? ApiKey, string? JwksEndpoint, string? Issuer, string? UserIdClaim, string? UserNameClaim); private record EndpointListDto(Guid Id, string Name, string? Tags, bool IsPublished, int DataStoreConfigurationId, string ConfigurationName, AuthenticationDto? Authentication); @@ -850,6 +865,7 @@ else || _editFilePath != _savedFilePath || _editConnectionString != _savedConnectionString || _editTrackingMode != _savedTrackingMode + || _editChangeRetentionDays != _savedChangeRetentionDays || _editAgentId != _savedAgentId; protected override async Task OnInitializedInteractiveAsync() @@ -873,10 +889,35 @@ else _editFilePath = _savedFilePath = _dataStore.FilePath ?? ""; _editConnectionString = _savedConnectionString = _dataStore.ConnectionString ?? ""; _editTrackingMode = _savedTrackingMode = _dataStore.TrackingMode ?? SqlServerDataStoreTrackingMode.ChangeTracking; + _editChangeRetentionDays = _savedChangeRetentionDays = _dataStore.ChangeRetentionDays ?? DefaultChangeRetentionDays; _editAgentId = _savedAgentId = _dataStore.AgentId ?? 0; } } + private async Task ApplyChangeRetention() + { + var response = await Http.PostAsJsonAsync($"api/provision/{DataStoreId}/change-retention", new { }); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(); + + _detailSuccessMessage = result?.EffectiveRetention is null + ? "Data store updated successfully. Change tracking is not enabled on this database, so no retention is in effect." + : $"Data store updated successfully. Change retention on the database is now {result.EffectiveRetention} {result.EffectiveRetentionUnit?.ToLowerInvariant()}" + + $", auto cleanup {(result.EffectiveAutoCleanup == true ? "on" : "off")}."; + } + else + { + var errors = await response.Content.ReadFromJsonAsync>(); + + // The value is saved either way - be explicit that the database was not changed, rather + // than letting a green "updated successfully" imply it was. + _detailSuccessMessage = null; + _detailErrorMessages = errors ?? ["The setting was saved but could not be applied to the database."]; + } + } + private async Task LoadAgents() { _agentOptions = await Http.GetFromJsonAsync>("api/agents?simple=true"); @@ -974,6 +1015,7 @@ else FilePath = _dataStore!.Type == DataStoreType.SQLite ? _editFilePath.Trim() : (string?)null, ConnectionString = _dataStore.Type is DataStoreType.SqlServer or DataStoreType.PostgreSQL ? _editConnectionString.Trim() : (string?)null, TrackingMode = _dataStore.Type == DataStoreType.SqlServer ? _editTrackingMode : (SqlServerDataStoreTrackingMode?)null, + ChangeRetentionDays = _dataStore.Type == DataStoreType.SqlServer ? _editChangeRetentionDays : (int?)null, AgentId = _editAgentId > 0 ? _editAgentId : (int?)null }; @@ -982,7 +1024,21 @@ else if (response.IsSuccessStatusCode) { _detailSuccessMessage = "Data store updated successfully."; + + var retentionChanged = _editChangeRetentionDays != _savedChangeRetentionDays; + await LoadDataStore(); + + // Storing the value changes nothing on a database that already has change tracking on, + // so the ALTER DATABASE has to be issued explicitly and its outcome reported. A setting + // that looks saved but was never applied is how a database quietly keeps a week of + // history when it was configured for a month. + if (retentionChanged + && _dataStore?.Type == DataStoreType.SqlServer + && _editTrackingMode == SqlServerDataStoreTrackingMode.ChangeTracking) + { + await ApplyChangeRetention(); + } } else {