From 08d58cc95b30fbb103870412dde5df6860da08f5 Mon Sep 17 00:00:00 2001 From: Adolfo Marinucci Date: Wed, 26 Aug 2026 11:45:21 +0200 Subject: [PATCH 1/2] Make change tracking retention configurable, defaulting to 30 days Retention was whatever CoreSync's default happened to be - seven days - with no way to see or change it. A client that stays offline longer than that window cannot resume an incremental sync and has to be reinitialized from a fresh snapshot, so the window has to cover how long a device is realistically expected to go without connectivity. Seven days does not. - Add ChangeRetentionDays to SqlServerDataStore, defaulting to 30, with a migration that backfills existing SQL Server data stores. The column is nullable because it belongs to one branch of the TPH hierarchy while the property is not, so without the backfill an existing data store would fail to materialize on a null read. - Pass it to the SqlServerCT configuration builder. Setting it explicitly is also what makes provisioning reconcile the retention on a database that already has change tracking on, where it would otherwise be ignored. - Expose it in the create wizard and the edit page, shown only for native change tracking, validated to 1-365 days on both sides. Saving a new value now issues the ALTER DATABASE and reports the retention read back from the database, so a value that was stored but could not be applied says so instead of showing a green "updated successfully". Also record the reason a sync session failed in the session's own traces. It reached DiagnosticItems already, but nothing led there from the session detail view - which is where anyone investigating a failed session looks first, and where the failures raised before the per-table trace loop showed a session that stopped for no stated reason. --- CoreSync | 2 +- src/Data/DataStore.cs | 18 + ...erDataStoreChangeRetentionDays.Designer.cs | 990 ++++++++++++++++++ ...ddSqlServerDataStoreChangeRetentionDays.cs | 39 + .../CoreSyncServerDbContextModelSnapshot.cs | 5 +- .../Controllers/DataStoresController.cs | 29 +- .../Controllers/ProvisionController.cs | 11 + src/Services/IProvisionService.cs | 45 + .../Implementation/ProvisionService.cs | 28 + .../Implementation/SyncProviderFactory.cs | 5 + .../Implementation/SyncSessionService.cs | 17 + .../Pages/DataStores/CreateDataStore.razor | 20 +- .../Pages/DataStores/EditDataStore.razor | 60 +- 13 files changed, 1261 insertions(+), 8 deletions(-) create mode 100644 src/Server/Data/Migrations/20260826094048_AddSqlServerDataStoreChangeRetentionDays.Designer.cs create mode 100644 src/Server/Data/Migrations/20260826094048_AddSqlServerDataStoreChangeRetentionDays.cs diff --git a/CoreSync b/CoreSync index 1b31860..76c397a 160000 --- a/CoreSync +++ b/CoreSync @@ -1 +1 @@ -Subproject commit 1b31860621022519822874b953dd5952414dd38f +Subproject commit 76c397ac17167b4fad17467d50bdee375767f413 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 { From f7cdd0339ffc64e72c24a29e8ae5754313ceae9e Mon Sep 17 00:00:00 2001 From: Adolfo Marinucci Date: Wed, 26 Aug 2026 13:02:07 +0200 Subject: [PATCH 2/2] Point CoreSync at merged main CoreSync#19 is merged, so the submodule can reference main rather than the feature branch it was stacked on. Same tree, so no behaviour change here. --- CoreSync | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CoreSync b/CoreSync index 76c397a..a3a4734 160000 --- a/CoreSync +++ b/CoreSync @@ -1 +1 @@ -Subproject commit 76c397ac17167b4fad17467d50bdee375767f413 +Subproject commit a3a4734fbf72fe5b0babfe2b1171cd42811cd0c9