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.
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
{