diff --git a/src/CoreSync.MySql/MySqlSyncTable.cs b/src/CoreSync.MySql/MySqlSyncTable.cs
index c12684d..9eba6bf 100644
--- a/src/CoreSync.MySql/MySqlSyncTable.cs
+++ b/src/CoreSync.MySql/MySqlSyncTable.cs
@@ -1,6 +1,7 @@
using MySqlConnector;
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
namespace CoreSync.MySql
@@ -96,6 +97,15 @@ value is string stringDate &&
{
return parsedDate;
}
+
+ //a time column travels as an invariant "c" formatted string (see SyncItemValue): it has
+ //no dedicated SyncItemValueType and that is also how SQLite stores it
+ if (column.DataType.Equals("time", StringComparison.OrdinalIgnoreCase) &&
+ value is string stringTime &&
+ TimeSpan.TryParse(stringTime, CultureInfo.InvariantCulture, out var parsedTime))
+ {
+ return parsedTime;
+ }
}
return value;
diff --git a/src/CoreSync.PostgreSQL/PostgreSQLColumn.cs b/src/CoreSync.PostgreSQL/PostgreSQLColumn.cs
index e222576..3cc0043 100644
--- a/src/CoreSync.PostgreSQL/PostgreSQLColumn.cs
+++ b/src/CoreSync.PostgreSQL/PostgreSQLColumn.cs
@@ -16,5 +16,23 @@ public PostgreSQLColumn(string name, string type, bool primaryKey = false)
public string Name { get; }
public string Type { get; }
public bool IsPrimaryKey { get; }
+
+ ///
+ /// True for the PostgreSQL types Npgsql can materialize as a .
+ ///
+ ///
+ /// time with time zone (timetz) is deliberately excluded: Npgsql materializes it
+ /// as a , which has no and is not
+ /// supported for synchronization.
+ ///
+ /// An interval is included, but only one made of days/hours/minutes/seconds round-trips:
+ /// an interval carrying months or years has no fixed length and Npgsql refuses to convert it to
+ /// a .
+ ///
+ ///
+ public bool IsTimeLike =>
+ Type.Equals("time without time zone", StringComparison.OrdinalIgnoreCase) ||
+ Type.Equals("time", StringComparison.OrdinalIgnoreCase) ||
+ Type.Equals("interval", StringComparison.OrdinalIgnoreCase);
}
}
\ No newline at end of file
diff --git a/src/CoreSync.PostgreSQL/PostgreSQLSyncProvider.cs b/src/CoreSync.PostgreSQL/PostgreSQLSyncProvider.cs
index 6d3e04e..5a748cf 100644
--- a/src/CoreSync.PostgreSQL/PostgreSQLSyncProvider.cs
+++ b/src/CoreSync.PostgreSQL/PostgreSQLSyncProvider.cs
@@ -490,6 +490,13 @@ private static ChangeType DetectChangeType(Dictionary values)
if (r.IsDBNull(columnOrdinal))
return null;
+ //a time/interval column must be materialized as a TimeSpan: Npgsql's default CLR type for
+ //'time without time zone' is TimeOnly, which has no SyncItemValueType (and cannot even be
+ //named from netstandard2.0). SyncItemValue then turns the TimeSpan into an invariant "c"
+ //formatted string, the representation every other provider understands.
+ if (table.Columns.TryGetValue(columnName, out var timeColumn) && timeColumn.IsTimeLike)
+ return r.GetFieldValue(columnOrdinal);
+
if (table.RecordType == null)
return r.GetValue(columnOrdinal);
diff --git a/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs b/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs
index 94bf024..dbaa0a6 100644
--- a/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs
+++ b/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs
@@ -1,6 +1,7 @@
using Npgsql;
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
namespace CoreSync.PostgreSQL
@@ -91,6 +92,17 @@ private object ConvertValueForColumn(string columnName, object? value)
}
}
+ // A time/interval column travels as an invariant "c" formatted string (see SyncItemValue):
+ // it has no dedicated SyncItemValueType and that is also how SQLite stores it.
+ if (Columns.TryGetValue(columnName, out var timeColumn) && timeColumn.IsTimeLike)
+ {
+ if (value is string timeString &&
+ TimeSpan.TryParse(timeString, CultureInfo.InvariantCulture, out var timeSpanValue))
+ {
+ return timeSpanValue;
+ }
+ }
+
return value;
}
diff --git a/src/CoreSync.SqlServer/Utils.cs b/src/CoreSync.SqlServer/Utils.cs
index 5be97e1..eca4538 100644
--- a/src/CoreSync.SqlServer/Utils.cs
+++ b/src/CoreSync.SqlServer/Utils.cs
@@ -21,7 +21,44 @@ public static object ConvertToSqlType(SyncItemValue value, SqlDbType dbType)
value.Value is decimal == false)
return Convert.ToDecimal(value.Value, CultureInfo.InvariantCulture);
+ //a time column travels as an invariant "c" formatted string (see SyncItemValue): it has no
+ //dedicated SyncItemValueType and that is also how SQLite stores it
+ if (dbType == SqlDbType.Time)
+ {
+ if (value.Value is string timeValue)
+ return ValidateTimeOfDay(TimeSpan.Parse(timeValue, CultureInfo.InvariantCulture));
+
+ if (value.Value is TimeSpan timeSpanValue)
+ return ValidateTimeOfDay(timeSpanValue);
+ }
+
+ //a tinyint column travels as an Int32 (or as a boolean, from MySql TINYINT(1)): SqlParameter
+ //does not narrow it on its own
+ if (dbType == SqlDbType.TinyInt &&
+ value.Value is byte == false)
+ return Convert.ToByte(value.Value, CultureInfo.InvariantCulture);
+
return value.Value;
}
+
+ ///
+ /// A SQL Server time column is a time of day: 00:00:00.0000000 to 23:59:59.9999999.
+ /// Other stores are wider - a MySql TIME spans -838:59:59 to 838:59:59 and a PostgreSQL
+ /// interval is unbounded - so a value coming from one of them can be out of range here.
+ /// Fail with a message naming the value instead of letting it surface as an opaque
+ /// SqlClient/SQL Server error, and never silently truncate it: that would be data loss.
+ ///
+ private static object ValidateTimeOfDay(TimeSpan value)
+ {
+ if (value < TimeSpan.Zero || value >= TimeSpan.FromDays(1))
+ {
+ throw new NotSupportedException(
+ $"Value '{value.ToString("c", CultureInfo.InvariantCulture)}' cannot be applied to a SQL Server 'time' column: " +
+ "it only holds a time of day, from 00:00:00.0000000 to 23:59:59.9999999. " +
+ "Map the source column to a wider type (for example bigint holding ticks) to synchronize it.");
+ }
+
+ return value;
+ }
}
}
diff --git a/src/CoreSync.SqlServerCT/Utils.cs b/src/CoreSync.SqlServerCT/Utils.cs
index 5237bb9..5ea8d98 100644
--- a/src/CoreSync.SqlServerCT/Utils.cs
+++ b/src/CoreSync.SqlServerCT/Utils.cs
@@ -19,7 +19,44 @@ public static object ConvertToSqlType(SyncItemValue value, SqlDbType dbType)
value.Value is decimal == false)
return Convert.ToDecimal(value.Value, CultureInfo.InvariantCulture);
+ //a time column travels as an invariant "c" formatted string (see SyncItemValue): it has no
+ //dedicated SyncItemValueType and that is also how SQLite stores it
+ if (dbType == SqlDbType.Time)
+ {
+ if (value.Value is string timeValue)
+ return ValidateTimeOfDay(TimeSpan.Parse(timeValue, CultureInfo.InvariantCulture));
+
+ if (value.Value is TimeSpan timeSpanValue)
+ return ValidateTimeOfDay(timeSpanValue);
+ }
+
+ //a tinyint column travels as an Int32 (or as a boolean, from MySql TINYINT(1)): SqlParameter
+ //does not narrow it on its own
+ if (dbType == SqlDbType.TinyInt &&
+ value.Value is byte == false)
+ return Convert.ToByte(value.Value, CultureInfo.InvariantCulture);
+
return value.Value;
}
+
+ ///
+ /// A SQL Server time column is a time of day: 00:00:00.0000000 to 23:59:59.9999999.
+ /// Other stores are wider - a MySql TIME spans -838:59:59 to 838:59:59 and a PostgreSQL
+ /// interval is unbounded - so a value coming from one of them can be out of range here.
+ /// Fail with a message naming the value instead of letting it surface as an opaque
+ /// SqlClient/SQL Server error, and never silently truncate it: that would be data loss.
+ ///
+ private static object ValidateTimeOfDay(TimeSpan value)
+ {
+ if (value < TimeSpan.Zero || value >= TimeSpan.FromDays(1))
+ {
+ throw new NotSupportedException(
+ $"Value '{value.ToString("c", CultureInfo.InvariantCulture)}' cannot be applied to a SQL Server 'time' column: " +
+ "it only holds a time of day, from 00:00:00.0000000 to 23:59:59.9999999. " +
+ "Map the source column to a wider type (for example bigint holding ticks) to synchronize it.");
+ }
+
+ return value;
+ }
}
}
diff --git a/src/CoreSync.Sqlite/SqliteSyncProvider.cs b/src/CoreSync.Sqlite/SqliteSyncProvider.cs
index 543cfd4..93d718e 100644
--- a/src/CoreSync.Sqlite/SqliteSyncProvider.cs
+++ b/src/CoreSync.Sqlite/SqliteSyncProvider.cs
@@ -497,7 +497,7 @@ public async Task GetChangesAsync(Guid otherStoreId, SyncFilterPa
while (await r.ReadAsync(cancellationToken))
{
var values = Enumerable.Range(0, r.FieldCount)
- .ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r));
+ .ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r, _logger));
items.Add(new SqliteSyncItem(table, ChangeType.Insert, values));
//snapshotItems.Add(values[table.PrimaryColumnName]);
_logger?.Trace($"[{_storeId}] Initial snapshot {items.Last()}");
@@ -519,7 +519,7 @@ public async Task GetChangesAsync(Guid otherStoreId, SyncFilterPa
{
while (await r.ReadAsync(cancellationToken))
{
- var values = Enumerable.Range(0, r.FieldCount).ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r));
+ var values = Enumerable.Range(0, r.FieldCount).ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r, _logger));
//if (snapshotItems.Contains(values[table.PrimaryColumnName]))
// continue;
@@ -537,7 +537,7 @@ public async Task GetChangesAsync(Guid otherStoreId, SyncFilterPa
{
while (await r.ReadAsync(cancellationToken))
{
- var values = Enumerable.Range(0, r.FieldCount).ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r));
+ var values = Enumerable.Range(0, r.FieldCount).ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r, _logger));
items.Add(new SqliteSyncItem(table, ChangeType.Delete, values));
_logger?.Trace($"[{_storeId}] Incremental delete {items.Last()}");
}
@@ -630,7 +630,7 @@ private static ChangeType DetectChangeType(Dictionary values)
return ChangeType.Insert;
}
- private static object? GetValueFromRecord(SqliteSyncTable table, string columnName, int columnOrdinal, SqliteDataReader r)
+ private static object? GetValueFromRecord(SqliteSyncTable table, string columnName, int columnOrdinal, SqliteDataReader r, ISyncLogger? logger)
{
if (r.IsDBNull(columnOrdinal))
return null;
@@ -640,7 +640,7 @@ private static ChangeType DetectChangeType(Dictionary values)
var property = table.RecordType.GetProperty(columnName);
if (property != null)
- return GetValueFromRecord(r, columnOrdinal, property.PropertyType);
+ return GetValueFromRecord(r, columnOrdinal, property.PropertyType, logger);
property = table.RecordType.GetProperties().FirstOrDefault(_ =>
{
@@ -654,18 +654,50 @@ private static ChangeType DetectChangeType(Dictionary values)
});
if (property != null)
- return GetValueFromRecord(r, columnOrdinal, property.PropertyType);
+ return GetValueFromRecord(r, columnOrdinal, property.PropertyType, logger);
//fallback to getvalue
return r.GetValue(columnOrdinal);
}
- private static object GetValueFromRecord(SqliteDataReader r, int columnOrdinal, Type propertyType)
+ private static object GetValueFromRecord(SqliteDataReader r, int columnOrdinal, Type propertyType, ISyncLogger? logger)
+ {
+ //a nullable property is read exactly like its underlying type: the caller has already
+ //excluded DBNull. Without this, every nullable column fell through to GetValue() and was
+ //read back as whatever SQLite stored it as (an INTEGER for a byte?, a string for a
+ //TimeSpan?/DateTime?), which does not round-trip to the same SyncItemValueType the
+ //server sends down for the very same column.
+ propertyType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
+
+ try
+ {
+ return GetTypedValueFromRecord(r, columnOrdinal, propertyType);
+ }
+ catch (Exception ex) when (
+ ex is FormatException ||
+ ex is InvalidCastException ||
+ ex is OverflowException ||
+ ex is ArgumentException)
+ {
+ //SQLite has no column types, only affinities, so a store written by something other
+ //than this library (or by an older version of the application) can hold a value the
+ //typed getter refuses to parse - a date in a legacy format, a decimal written as a
+ //REAL, and so on. Returning the raw stored value keeps such a store synchronizable,
+ //which is what this method did for every nullable column before typed reads were
+ //extended to them.
+ logger?.Warning($"Unable to read column {r.GetName(columnOrdinal)} as {propertyType}, falling back to the raw stored value: {ex.Message}");
+ return r.GetValue(columnOrdinal);
+ }
+ }
+
+ private static object GetTypedValueFromRecord(SqliteDataReader r, int columnOrdinal, Type propertyType)
{
if (propertyType == typeof(string))
return r.GetString(columnOrdinal);
if (propertyType == typeof(DateTime))
return r.GetDateTime(columnOrdinal);
+ if (propertyType == typeof(TimeSpan))
+ return r.GetTimeSpan(columnOrdinal);
if (propertyType == typeof(int))
return r.GetInt32(columnOrdinal);
if (propertyType == typeof(bool))
diff --git a/src/CoreSync.Tests/ColumnTypeMappingTests.MySqlPostgreSQL.cs b/src/CoreSync.Tests/ColumnTypeMappingTests.MySqlPostgreSQL.cs
new file mode 100644
index 0000000..d650044
--- /dev/null
+++ b/src/CoreSync.Tests/ColumnTypeMappingTests.MySqlPostgreSQL.cs
@@ -0,0 +1,820 @@
+using CoreSync.MySql;
+using CoreSync.PostgreSQL;
+using CoreSync.Sqlite;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using MySqlConnector;
+using Npgsql;
+using Shouldly;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace CoreSync.Tests;
+
+///
+/// The MySql and PostgreSQL half of : the same tinyint-like and
+/// time-like round trips, plus the behaviours that are specific to those two stores.
+///
+/// MySql reports a TINYINT(1) as a , a TINYINT as an
+/// and a TINYINT UNSIGNED as a - three different CLR types
+/// for one SQL type, and only one of them used to be carryable.
+/// A MySql TIME spans -838:59:59 to 838:59:59 and a PostgreSQL interval
+/// is unbounded, so both can hold values a SQL Server time column cannot. The "c" wire
+/// format carries them; see ApplyChanges_TimeValueOutsideTimeOfDayRange_IsRejectedWithAClearMessage
+/// for what happens when one is pushed into SQL Server anyway.
+/// PostgreSQL's default CLR type for time without time zone is
+/// TimeOnly, which has no and cannot even be named from
+/// netstandard2.0, so the provider materializes those columns as a explicitly.
+/// timetz and a month-bearing interval stay unsupported, on purpose.
+///
+///
+public partial class ColumnTypeMappingTests
+{
+ private static string MySqlConnectionString => Environment.GetEnvironmentVariable("CORE-SYNC_MYSQL_CONNECTION_STRING") ??
+ "Server=localhost;Port=3306;Database=coresync_test;User=root;Password=test123;GuidFormat=Char36";
+
+ private static string PostgreSQLConnectionString => Environment.GetEnvironmentVariable("CORE-SYNC_POSTGRESQL_CONNECTION_STRING") ??
+ "Host=localhost;Port=5432;Database=coresync_test;Username=coresync;Password=test123";
+
+ private static string MySqlConnectionStringFor(string database)
+ => new MySqlConnectionStringBuilder(MySqlConnectionString) { Database = database }.ConnectionString;
+
+ private static string PostgreSQLConnectionStringFor(string database)
+ => new NpgsqlConnectionStringBuilder(PostgreSQLConnectionString) { Database = database }.ConnectionString;
+
+ #region Model
+
+ ///
+ /// The MySql/PostgreSQL counterpart of . It carries no
+ /// column: how a Guid is spelled differs per store (MySql CHAR(36) uppercased, PostgreSQL
+ /// uuid) and that is unrelated to the types under test, which
+ /// already covers.
+ ///
+ public class Reading
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public byte StartMonth { get; set; }
+ public byte? EndMonth { get; set; }
+ public short Zone { get; set; }
+ public TimeSpan CountTime { get; set; }
+ public TimeSpan? Duration { get; set; }
+ public decimal Weight { get; set; }
+ public DateTime Recorded { get; set; }
+ }
+
+ /// A single time-like column, used for the values a time of day cannot express.
+ public class Span
+ {
+ public int Id { get; set; }
+ public TimeSpan Value { get; set; }
+ }
+
+ private sealed class ReadingDbContext : DbContext
+ {
+ private readonly string _connectionString;
+
+ public ReadingDbContext(string connectionString) => _connectionString = connectionString;
+
+ public DbSet Readings => Set();
+ public DbSet Spans => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseSqlite(_connectionString);
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Readings");
+ entity.HasKey(_ => _.Id);
+ entity.Property(_ => _.Id).ValueGeneratedNever();
+ entity.Property(_ => _.Weight).HasColumnType("TEXT");
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Spans");
+ entity.HasKey(_ => _.Id);
+ entity.Property(_ => _.Id).ValueGeneratedNever();
+ });
+ }
+ }
+
+ private const string SqliteReadingsDdl = @"
+ CREATE TABLE [Readings] (
+ [Id] INTEGER NOT NULL PRIMARY KEY,
+ [Name] TEXT NOT NULL,
+ [StartMonth] INTEGER NOT NULL,
+ [EndMonth] INTEGER NULL,
+ [Zone] INTEGER NOT NULL,
+ [CountTime] TEXT NOT NULL,
+ [Duration] TEXT NULL,
+ [Weight] TEXT NOT NULL,
+ [Recorded] TEXT NOT NULL
+ );
+ CREATE TABLE [Spans] (
+ [Id] INTEGER NOT NULL PRIMARY KEY,
+ [Value] TEXT NOT NULL
+ );";
+
+ // MySql resolves TIME/DATETIME to microseconds at most, PostgreSQL likewise, while a TimeSpan tick is
+ // 100ns. Every value below is therefore a whole number of microseconds, so a mismatch means a real
+ // conversion bug rather than the store's declared resolution.
+ private static TimeSpan MicroDuration => new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890));
+ private static TimeSpan MicroUploadedDuration => new TimeSpan(0, 12, 34, 56).Add(TimeSpan.FromTicks(7891230));
+ private static DateTime MicroRecorded => new DateTime(2026, 9, 15, 21, 21, 33).AddTicks(1234560);
+ private static DateTime MicroUploadedRecorded => new DateTime(2026, 2, 3, 4, 5, 6).AddTicks(7654320);
+
+ #endregion
+
+ #region Helpers - MySql
+
+ private const string MySqlDdl = @"
+ CREATE TABLE `Readings` (
+ `Id` INT NOT NULL PRIMARY KEY,
+ `Name` VARCHAR(100) NOT NULL,
+ `StartMonth` TINYINT UNSIGNED NOT NULL,
+ `EndMonth` TINYINT UNSIGNED NULL,
+ `Zone` SMALLINT NOT NULL,
+ `CountTime` TIME NOT NULL,
+ `Duration` TIME(6) NULL,
+ `Weight` DECIMAL(10,3) NOT NULL,
+ `Recorded` DATETIME(6) NOT NULL
+ );
+ CREATE TABLE `Spans` (
+ `Id` INT NOT NULL PRIMARY KEY,
+ `Value` TIME NOT NULL
+ );
+ CREATE TABLE `TinyIntFlavors` (
+ `Id` INT NOT NULL PRIMARY KEY,
+ `Flag` TINYINT(1) NOT NULL,
+ `Signed` TINYINT NOT NULL,
+ `Unsigned` TINYINT UNSIGNED NOT NULL
+ );";
+
+ private static async Task ExecuteMySqlNonQuery(string connectionString, string commandText)
+ {
+ using var conn = new MySqlConnection(connectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = commandText;
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private static async Task CreateMySqlDatabase(string dbName)
+ {
+ MySqlConnection.ClearAllPools();
+ using var conn = new MySqlConnection(MySqlConnectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = $"DROP DATABASE IF EXISTS `{dbName}`; CREATE DATABASE `{dbName}`;";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private static async Task DropMySqlDatabase(string dbName)
+ {
+ MySqlConnection.ClearAllPools();
+ using var conn = new MySqlConnection(MySqlConnectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = $"DROP DATABASE IF EXISTS `{dbName}`;";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private static async Task ReadReadingFromMySql(string connectionString, int id)
+ {
+ using var conn = new MySqlConnection(connectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT `Name`,`StartMonth`,`EndMonth`,`Zone`,`CountTime`,`Duration`,`Weight`,`Recorded` FROM `Readings` WHERE `Id` = @id";
+ cmd.Parameters.AddWithValue("@id", id);
+ using var reader = await cmd.ExecuteReaderAsync();
+ if (!await reader.ReadAsync())
+ return null;
+
+ return new Reading
+ {
+ Id = id,
+ Name = reader.GetString(0),
+ // GetByte/GetTimeSpan: the column must really be a tinyint/time on arrival.
+ StartMonth = reader.GetByte(1),
+ EndMonth = reader.IsDBNull(2) ? null : reader.GetByte(2),
+ Zone = reader.GetInt16(3),
+ CountTime = reader.GetTimeSpan(4),
+ Duration = reader.IsDBNull(5) ? null : reader.GetTimeSpan(5),
+ Weight = reader.GetDecimal(6),
+ Recorded = reader.GetDateTime(7)
+ };
+ }
+
+ private static async Task ReadSpanFromMySql(string connectionString, int id)
+ {
+ using var conn = new MySqlConnection(connectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT `Value` FROM `Spans` WHERE `Id` = @id";
+ cmd.Parameters.AddWithValue("@id", id);
+ using var reader = await cmd.ExecuteReaderAsync();
+ (await reader.ReadAsync()).ShouldBeTrue($"row {id} must exist in Spans");
+ return reader.GetTimeSpan(0);
+ }
+
+ private static MySqlSyncProvider CreateMySqlProvider(string connectionString, params string[] tables)
+ {
+ var builder = new MySqlSyncConfigurationBuilder(connectionString);
+ foreach (var table in tables)
+ builder.Table(table);
+ return new MySqlSyncProvider(builder.Build(), logger: new ConsoleLogger("MYSQL"));
+ }
+
+ #endregion
+
+ #region Helpers - PostgreSQL
+
+ // Identifiers are quoted so PostgreSQL keeps their case: the column names have to match the ones the
+ // SQLite peer sends, and an unquoted identifier would be folded to lower case.
+ private const string PostgreSQLDdl = @"
+ CREATE TABLE ""Readings"" (
+ ""Id"" INTEGER NOT NULL PRIMARY KEY,
+ ""Name"" TEXT NOT NULL,
+ ""StartMonth"" SMALLINT NOT NULL,
+ ""EndMonth"" SMALLINT NULL,
+ ""Zone"" SMALLINT NOT NULL,
+ ""CountTime"" TIME(6) NOT NULL,
+ ""Duration"" TIME(6) NULL,
+ ""Weight"" NUMERIC(10,3) NOT NULL,
+ ""Recorded"" TIMESTAMP NOT NULL
+ );
+ CREATE TABLE ""Spans"" (
+ ""Id"" INTEGER NOT NULL PRIMARY KEY,
+ ""Value"" INTERVAL NOT NULL
+ );";
+
+ private static async Task ExecutePostgreSQLNonQuery(string connectionString, string commandText)
+ {
+ using var conn = new NpgsqlConnection(connectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = commandText;
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private static async Task CreatePostgreSQLDatabase(string dbName)
+ {
+ NpgsqlConnection.ClearAllPools();
+ using var conn = new NpgsqlConnection(PostgreSQLConnectionString);
+ await conn.OpenAsync();
+ using (var drop = conn.CreateCommand())
+ {
+ drop.CommandText = $"DROP DATABASE IF EXISTS \"{dbName}\" WITH (FORCE)";
+ await drop.ExecuteNonQueryAsync();
+ }
+ using var create = conn.CreateCommand();
+ create.CommandText = $"CREATE DATABASE \"{dbName}\"";
+ await create.ExecuteNonQueryAsync();
+ }
+
+ private static async Task DropPostgreSQLDatabase(string dbName)
+ {
+ NpgsqlConnection.ClearAllPools();
+ using var conn = new NpgsqlConnection(PostgreSQLConnectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = $"DROP DATABASE IF EXISTS \"{dbName}\" WITH (FORCE)";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private static async Task ReadReadingFromPostgreSQL(string connectionString, int id)
+ {
+ using var conn = new NpgsqlConnection(connectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"SELECT ""Name"",""StartMonth"",""EndMonth"",""Zone"",""CountTime"",""Duration"",""Weight"",""Recorded"" FROM ""Readings"" WHERE ""Id"" = $1";
+ cmd.Parameters.Add(new NpgsqlParameter { Value = id });
+ using var reader = await cmd.ExecuteReaderAsync();
+ if (!await reader.ReadAsync())
+ return null;
+
+ return new Reading
+ {
+ Id = id,
+ Name = reader.GetString(0),
+ StartMonth = (byte)reader.GetInt16(1),
+ EndMonth = reader.IsDBNull(2) ? null : (byte)reader.GetInt16(2),
+ Zone = reader.GetInt16(3),
+ // GetFieldValue: a 'time without time zone' column materializes as TimeOnly by
+ // default, so asking for a TimeSpan proves the column really is a time.
+ CountTime = reader.GetFieldValue(4),
+ Duration = reader.IsDBNull(5) ? null : reader.GetFieldValue(5),
+ Weight = reader.GetDecimal(6),
+ Recorded = reader.GetDateTime(7)
+ };
+ }
+
+ private static async Task ReadSpanFromPostgreSQL(string connectionString, int id)
+ {
+ using var conn = new NpgsqlConnection(connectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"SELECT ""Value"" FROM ""Spans"" WHERE ""Id"" = $1";
+ cmd.Parameters.Add(new NpgsqlParameter { Value = id });
+ using var reader = await cmd.ExecuteReaderAsync();
+ (await reader.ReadAsync()).ShouldBeTrue($"row {id} must exist in Spans");
+ return reader.GetFieldValue(0);
+ }
+
+ private static PostgreSQLSyncProvider CreatePostgreSQLProvider(string connectionString, params string[] tables)
+ {
+ var builder = new PostgreSQLSyncConfigurationBuilder(connectionString);
+ foreach (var table in tables)
+ builder.Table(table);
+ return new PostgreSQLSyncProvider(builder.Build(), logger: new ConsoleLogger("PGSQL"));
+ }
+
+ #endregion
+
+ #region Helpers - SQLite side of these tests
+
+ private static SqliteSyncProvider CreateSqliteReadingsProvider(string connectionString)
+ => new(new SqliteSyncConfigurationBuilder(connectionString)
+ .Table("Readings")
+ .Table("Spans")
+ .Build(),
+ logger: new ConsoleLogger("LOC"));
+
+ #endregion
+
+ #region MySql ⇄ SQLite round trips
+
+ private static async Task RunMySqlRoundTrip(string testName, Transport transport)
+ {
+ var dbName = $"coresync_types_{testName.ToLowerInvariant()}";
+ var sqliteFile = CreateSqliteFile(testName);
+ var sqliteConnStr = $"Data Source={sqliteFile}";
+
+ SyncTestServer? server = null;
+
+ try
+ {
+ await CreateMySqlDatabase(dbName);
+ var mysqlConnStr = MySqlConnectionStringFor(dbName);
+ await ExecuteMySqlNonQuery(mysqlConnStr, MySqlDdl);
+ await ExecuteSqliteNonQuery(sqliteConnStr, SqliteReadingsDdl);
+
+ var remoteProvider = CreateMySqlProvider(mysqlConnStr, "Readings", "Spans");
+ await remoteProvider.ApplyProvisionAsync();
+
+ var localProvider = CreateSqliteReadingsProvider(sqliteConnStr);
+ await localProvider.ApplyProvisionAsync();
+
+ (var remote, server) = Wrap(remoteProvider, transport);
+ var agent = new SyncAgent(localProvider, remote);
+
+ // ---------- MySql → SQLite ----------
+
+ await ExecuteMySqlNonQuery(mysqlConnStr, @"
+ INSERT INTO `Readings` (`Id`,`Name`,`StartMonth`,`EndMonth`,`Zone`,`CountTime`,`Duration`,`Weight`,`Recorded`)
+ VALUES
+ (1, 'Spring', 3, 250, -5, '14:30:00', '01:02:03.456789', 12.345, '2026-09-15 21:21:33.123456'),
+ (2, 'Winter', 0, NULL, 32767, '00:00:00', NULL, 0.001, '2026-01-01 00:00:00.000000');
+ INSERT INTO `Spans` (`Id`,`Value`) VALUES
+ (1, '100:00:00'),
+ (2, '-01:02:03'),
+ (3, '838:59:59');");
+
+ await agent.SynchronizeAsync();
+
+ {
+ using var db = new ReadingDbContext(sqliteConnStr);
+
+ var full = await db.Readings.SingleAsync(_ => _.Id == 1);
+ full.StartMonth.ShouldBe((byte)3);
+ // 250 is out of range for the sbyte MySql reports a signed TINYINT as: it proves the
+ // value was widened, not reinterpreted.
+ full.EndMonth.ShouldBe((byte)250);
+ full.Zone.ShouldBe((short)-5);
+ full.CountTime.ShouldBe(new TimeSpan(14, 30, 0));
+ full.Duration.ShouldBe(MicroDuration);
+ full.Weight.ShouldBe(12.345m);
+ full.Recorded.ShouldBe(MicroRecorded);
+
+ var nulls = await db.Readings.SingleAsync(_ => _.Id == 2);
+ nulls.EndMonth.ShouldBeNull();
+ nulls.Duration.ShouldBeNull();
+ nulls.CountTime.ShouldBe(TimeSpan.Zero);
+
+ // A MySql TIME is not a time of day, and the "c" wire format carries all of it.
+ (await db.Spans.SingleAsync(_ => _.Id == 1)).Value.ShouldBe(TimeSpan.FromHours(100));
+ (await db.Spans.SingleAsync(_ => _.Id == 2)).Value.ShouldBe(new TimeSpan(0, -1, -2, -3));
+ (await db.Spans.SingleAsync(_ => _.Id == 3)).Value.ShouldBe(new TimeSpan(838, 59, 59));
+ }
+
+ // ---------- SQLite → MySql ----------
+
+ {
+ using var db = new ReadingDbContext(sqliteConnStr);
+ db.Readings.Add(new Reading
+ {
+ Id = 100,
+ Name = "Uploaded",
+ StartMonth = 255,
+ EndMonth = 1,
+ Zone = -32768,
+ CountTime = new TimeSpan(23, 59, 59),
+ Duration = MicroUploadedDuration,
+ Weight = 999.999m,
+ Recorded = MicroUploadedRecorded
+ });
+ db.Readings.Add(new Reading
+ {
+ Id = 101,
+ Name = "UploadedNulls",
+ StartMonth = 0,
+ EndMonth = null,
+ Zone = 1,
+ CountTime = TimeSpan.Zero,
+ Duration = null,
+ Weight = 0m,
+ Recorded = new DateTime(2026, 3, 4, 5, 6, 7)
+ });
+ db.Spans.Add(new Span { Id = 100, Value = TimeSpan.FromHours(-100) });
+ db.Spans.Add(new Span { Id = 101, Value = new TimeSpan(500, 0, 0) });
+ await db.SaveChangesAsync();
+ }
+
+ await agent.SynchronizeAsync();
+
+ var uploaded = await ReadReadingFromMySql(mysqlConnStr, 100);
+ uploaded.ShouldNotBeNull();
+ uploaded.StartMonth.ShouldBe((byte)255);
+ uploaded.EndMonth.ShouldBe((byte)1);
+ uploaded.Zone.ShouldBe((short)-32768);
+ uploaded.CountTime.ShouldBe(new TimeSpan(23, 59, 59));
+ uploaded.Duration.ShouldBe(MicroUploadedDuration);
+ uploaded.Weight.ShouldBe(999.999m);
+ uploaded.Recorded.ShouldBe(MicroUploadedRecorded);
+
+ var uploadedNulls = await ReadReadingFromMySql(mysqlConnStr, 101);
+ uploadedNulls.ShouldNotBeNull();
+ uploadedNulls.StartMonth.ShouldBe((byte)0);
+ uploadedNulls.EndMonth.ShouldBeNull();
+ uploadedNulls.Duration.ShouldBeNull();
+
+ (await ReadSpanFromMySql(mysqlConnStr, 100)).ShouldBe(TimeSpan.FromHours(-100));
+ (await ReadSpanFromMySql(mysqlConnStr, 101)).ShouldBe(new TimeSpan(500, 0, 0));
+ }
+ finally
+ {
+ server?.Dispose();
+ DeleteSqliteFile(sqliteFile);
+ await DropMySqlDatabase(dbName);
+ }
+ }
+
+ [TestMethod]
+ public Task MySqlAndSqlite_RoundTripTinyIntAndTime_Direct()
+ => RunMySqlRoundTrip("MySqlDirect", Transport.Direct);
+
+ [TestMethod]
+ public Task MySqlAndSqlite_RoundTripTinyIntAndTime_HttpJson()
+ => RunMySqlRoundTrip("MySqlHttpJson", Transport.HttpJson);
+
+ [TestMethod]
+ public Task MySqlAndSqlite_RoundTripTinyIntAndTime_HttpBinary()
+ => RunMySqlRoundTrip("MySqlHttpBinary", Transport.HttpBinary);
+
+ ///
+ /// One SQL type, three CLR types: MySqlConnector reports TINYINT(1) as a ,
+ /// TINYINT as an and TINYINT UNSIGNED as a .
+ /// Only the first was carryable before; all three must be now, and each must land on the
+ /// that keeps its value intact.
+ ///
+ [TestMethod]
+ public async Task MySql_ReportsEveryTinyIntFlavourWithACarryableType()
+ {
+ var dbName = "coresync_types_tinyintflavors";
+
+ try
+ {
+ await CreateMySqlDatabase(dbName);
+ var connStr = MySqlConnectionStringFor(dbName);
+ await ExecuteMySqlNonQuery(connStr, MySqlDdl);
+
+ var provider = CreateMySqlProvider(connStr, "TinyIntFlavors");
+ await provider.ApplyProvisionAsync();
+
+ await ExecuteMySqlNonQuery(connStr, @"
+ INSERT INTO `TinyIntFlavors` (`Id`,`Flag`,`Signed`,`Unsigned`) VALUES
+ (1, 1, -128, 255),
+ (2, 0, 127, 0);");
+
+ var changes = await provider.GetChangesAsync(Guid.NewGuid());
+ changes.Items.Count.ShouldBe(2);
+
+ var first = changes.Items.Single(_ => Convert.ToInt32(_.Values["Id"].Value) == 1).Values;
+ first["Flag"].Type.ShouldBe(SyncItemValueType.Boolean, "MySql reports TINYINT(1) as a bool");
+ first["Flag"].Value.ShouldBe(true);
+ first["Signed"].Type.ShouldBe(SyncItemValueType.Int32, "MySql reports TINYINT as an sbyte");
+ first["Signed"].Value.ShouldBeOfType().ShouldBe(-128);
+ first["Unsigned"].Type.ShouldBe(SyncItemValueType.Int32, "MySql reports TINYINT UNSIGNED as a byte");
+ first["Unsigned"].Value.ShouldBeOfType().ShouldBe(255);
+
+ var second = changes.Items.Single(_ => Convert.ToInt32(_.Values["Id"].Value) == 2).Values;
+ second["Flag"].Value.ShouldBe(false);
+ second["Signed"].Value.ShouldBe(127);
+ second["Unsigned"].Value.ShouldBe(0);
+ }
+ finally
+ {
+ await DropMySqlDatabase(dbName);
+ }
+ }
+
+ #endregion
+
+ #region PostgreSQL ⇄ SQLite round trips
+
+ private static async Task RunPostgreSQLRoundTrip(string testName, Transport transport)
+ {
+ var dbName = $"coresync_types_{testName.ToLowerInvariant()}";
+ var sqliteFile = CreateSqliteFile(testName);
+ var sqliteConnStr = $"Data Source={sqliteFile}";
+
+ SyncTestServer? server = null;
+
+ try
+ {
+ await CreatePostgreSQLDatabase(dbName);
+ var pgConnStr = PostgreSQLConnectionStringFor(dbName);
+ await ExecutePostgreSQLNonQuery(pgConnStr, PostgreSQLDdl);
+ await ExecuteSqliteNonQuery(sqliteConnStr, SqliteReadingsDdl);
+
+ var remoteProvider = CreatePostgreSQLProvider(pgConnStr, "Readings", "Spans");
+ await remoteProvider.ApplyProvisionAsync();
+
+ var localProvider = CreateSqliteReadingsProvider(sqliteConnStr);
+ await localProvider.ApplyProvisionAsync();
+
+ (var remote, server) = Wrap(remoteProvider, transport);
+ var agent = new SyncAgent(localProvider, remote);
+
+ // ---------- PostgreSQL → SQLite ----------
+
+ await ExecutePostgreSQLNonQuery(pgConnStr, @"
+ INSERT INTO ""Readings"" (""Id"",""Name"",""StartMonth"",""EndMonth"",""Zone"",""CountTime"",""Duration"",""Weight"",""Recorded"")
+ VALUES
+ (1, 'Spring', 3, 250, -5, TIME '14:30:00', TIME '01:02:03.456789', 12.345, TIMESTAMP '2026-09-15 21:21:33.123456'),
+ (2, 'Winter', 0, NULL, 32767, TIME '00:00:00', NULL, 0.001, TIMESTAMP '2026-01-01 00:00:00');
+ INSERT INTO ""Spans"" (""Id"",""Value"") VALUES
+ (1, INTERVAL '100 hours'),
+ (2, INTERVAL '-1 hour -2 minutes -3 seconds'),
+ (3, INTERVAL '2 days 3 hours');");
+
+ await agent.SynchronizeAsync();
+
+ {
+ using var db = new ReadingDbContext(sqliteConnStr);
+
+ var full = await db.Readings.SingleAsync(_ => _.Id == 1);
+ full.StartMonth.ShouldBe((byte)3);
+ full.EndMonth.ShouldBe((byte)250);
+ full.Zone.ShouldBe((short)-5);
+ // PostgreSQL materializes a 'time without time zone' as a TimeOnly by default, which has
+ // no SyncItemValueType: the provider has to ask for a TimeSpan explicitly.
+ full.CountTime.ShouldBe(new TimeSpan(14, 30, 0));
+ full.Duration.ShouldBe(MicroDuration);
+ full.Weight.ShouldBe(12.345m);
+ full.Recorded.ShouldBe(MicroRecorded);
+
+ var nulls = await db.Readings.SingleAsync(_ => _.Id == 2);
+ nulls.EndMonth.ShouldBeNull();
+ nulls.Duration.ShouldBeNull();
+
+ (await db.Spans.SingleAsync(_ => _.Id == 1)).Value.ShouldBe(TimeSpan.FromHours(100));
+ (await db.Spans.SingleAsync(_ => _.Id == 2)).Value.ShouldBe(new TimeSpan(0, -1, -2, -3));
+ (await db.Spans.SingleAsync(_ => _.Id == 3)).Value.ShouldBe(new TimeSpan(2, 3, 0, 0));
+ }
+
+ // ---------- SQLite → PostgreSQL ----------
+
+ {
+ using var db = new ReadingDbContext(sqliteConnStr);
+ db.Readings.Add(new Reading
+ {
+ Id = 100,
+ Name = "Uploaded",
+ StartMonth = 255,
+ EndMonth = 1,
+ Zone = -32768,
+ CountTime = new TimeSpan(23, 59, 59),
+ Duration = MicroUploadedDuration,
+ Weight = 999.999m,
+ Recorded = MicroUploadedRecorded
+ });
+ db.Readings.Add(new Reading
+ {
+ Id = 101,
+ Name = "UploadedNulls",
+ StartMonth = 0,
+ EndMonth = null,
+ Zone = 1,
+ CountTime = TimeSpan.Zero,
+ Duration = null,
+ Weight = 0m,
+ Recorded = new DateTime(2026, 3, 4, 5, 6, 7)
+ });
+ db.Spans.Add(new Span { Id = 100, Value = TimeSpan.FromHours(-100) });
+ db.Spans.Add(new Span { Id = 101, Value = new TimeSpan(500, 0, 0) });
+ await db.SaveChangesAsync();
+ }
+
+ await agent.SynchronizeAsync();
+
+ var uploaded = await ReadReadingFromPostgreSQL(pgConnStr, 100);
+ uploaded.ShouldNotBeNull();
+ uploaded.StartMonth.ShouldBe((byte)255);
+ uploaded.EndMonth.ShouldBe((byte)1);
+ uploaded.Zone.ShouldBe((short)-32768);
+ uploaded.CountTime.ShouldBe(new TimeSpan(23, 59, 59));
+ uploaded.Duration.ShouldBe(MicroUploadedDuration);
+ uploaded.Weight.ShouldBe(999.999m);
+ uploaded.Recorded.ShouldBe(MicroUploadedRecorded);
+
+ var uploadedNulls = await ReadReadingFromPostgreSQL(pgConnStr, 101);
+ uploadedNulls.ShouldNotBeNull();
+ uploadedNulls.StartMonth.ShouldBe((byte)0);
+ uploadedNulls.EndMonth.ShouldBeNull();
+ uploadedNulls.Duration.ShouldBeNull();
+
+ (await ReadSpanFromPostgreSQL(pgConnStr, 100)).ShouldBe(TimeSpan.FromHours(-100));
+ (await ReadSpanFromPostgreSQL(pgConnStr, 101)).ShouldBe(new TimeSpan(500, 0, 0));
+ }
+ finally
+ {
+ server?.Dispose();
+ DeleteSqliteFile(sqliteFile);
+ await DropPostgreSQLDatabase(dbName);
+ }
+ }
+
+ [TestMethod]
+ public Task PostgreSQLAndSqlite_RoundTripSmallIntAndTime_Direct()
+ => RunPostgreSQLRoundTrip("PgDirect", Transport.Direct);
+
+ [TestMethod]
+ public Task PostgreSQLAndSqlite_RoundTripSmallIntAndTime_HttpJson()
+ => RunPostgreSQLRoundTrip("PgHttpJson", Transport.HttpJson);
+
+ [TestMethod]
+ public Task PostgreSQLAndSqlite_RoundTripSmallIntAndTime_HttpBinary()
+ => RunPostgreSQLRoundTrip("PgHttpBinary", Transport.HttpBinary);
+
+ ///
+ /// The two PostgreSQL shapes that stay unsupported, asserted so the boundary is explicit rather than
+ /// implied: a timetz materializes as a , and an interval carrying
+ /// months has no fixed length so it cannot become a at all.
+ ///
+ [TestMethod]
+ public async Task PostgreSQL_TimetzAndMonthBearingIntervalsRemainUnsupported()
+ {
+ var dbName = "coresync_types_pgunsupported";
+
+ try
+ {
+ await CreatePostgreSQLDatabase(dbName);
+ var connStr = PostgreSQLConnectionStringFor(dbName);
+ await ExecutePostgreSQLNonQuery(connStr, @"
+ CREATE TABLE ""WithTimetz"" (
+ ""Id"" INTEGER NOT NULL PRIMARY KEY,
+ ""Moment"" TIMETZ NOT NULL
+ );
+ CREATE TABLE ""Spans"" (
+ ""Id"" INTEGER NOT NULL PRIMARY KEY,
+ ""Value"" INTERVAL NOT NULL
+ );");
+
+ var provider = CreatePostgreSQLProvider(connStr, "WithTimetz", "Spans");
+ await provider.ApplyProvisionAsync();
+
+ await ExecutePostgreSQLNonQuery(connStr, @"
+ INSERT INTO ""WithTimetz"" (""Id"",""Moment"") VALUES (1, TIMETZ '14:30:00+02');");
+
+ var timetzFailure = await Should.ThrowAsync(() => provider.GetChangesAsync(Guid.NewGuid()));
+ var notSupported = FindInChain(timetzFailure);
+ notSupported.ShouldNotBeNull("a timetz column has no SyncItemValueType and must say so");
+ notSupported.Message.ShouldContain("DateTimeOffset");
+
+ await ExecutePostgreSQLNonQuery(connStr, @"DELETE FROM ""WithTimetz""; DELETE FROM __core_sync_ct;");
+ await ExecutePostgreSQLNonQuery(connStr, @"INSERT INTO ""Spans"" (""Id"",""Value"") VALUES (1, INTERVAL '1 month');");
+
+ // A month has no fixed length, so Npgsql refuses to hand it over as a TimeSpan. The failure is
+ // Npgsql's, not this library's, and it is the correct answer: there is nothing to carry.
+ await Should.ThrowAsync(() => provider.GetChangesAsync(Guid.NewGuid()));
+ }
+ finally
+ {
+ await DropPostgreSQLDatabase(dbName);
+ }
+ }
+
+ #endregion
+
+ #region Old peer compatibility - MySql and PostgreSQL
+
+ ///
+ /// Backward compatibility, old client → new server, for the two providers that were never able to
+ /// carry these columns at all. The payload is the one a released SQLite client produces: a nullable
+ /// byte column read with GetValue() arrives as an and a
+ /// nullable TimeSpan column as the raw stored text.
+ ///
+ [TestMethod]
+ public async Task OldClientPayload_IsStillAcceptedByMySql()
+ {
+ var dbName = "coresync_types_oldpeer_mysql";
+
+ try
+ {
+ await CreateMySqlDatabase(dbName);
+ var connStr = MySqlConnectionStringFor(dbName);
+ await ExecuteMySqlNonQuery(connStr, MySqlDdl);
+
+ var provider = CreateMySqlProvider(connStr, "Readings", "Spans");
+ await provider.ApplyProvisionAsync();
+
+ await provider.ApplyChangesAsync(OldClientReadingChangeSet());
+
+ var applied = await ReadReadingFromMySql(connStr, 1);
+ applied.ShouldNotBeNull();
+ applied.StartMonth.ShouldBe((byte)3);
+ applied.EndMonth.ShouldBe((byte)250);
+ applied.Zone.ShouldBe((short)-5);
+ applied.CountTime.ShouldBe(new TimeSpan(14, 30, 0));
+ applied.Duration.ShouldBe(MicroDuration);
+ applied.Weight.ShouldBe(12.345m);
+ applied.Recorded.ShouldBe(MicroRecorded);
+ }
+ finally
+ {
+ await DropMySqlDatabase(dbName);
+ }
+ }
+
+ [TestMethod]
+ public async Task OldClientPayload_IsStillAcceptedByPostgreSQL()
+ {
+ var dbName = "coresync_types_oldpeer_pg";
+
+ try
+ {
+ await CreatePostgreSQLDatabase(dbName);
+ var connStr = PostgreSQLConnectionStringFor(dbName);
+ await ExecutePostgreSQLNonQuery(connStr, PostgreSQLDdl);
+
+ var provider = CreatePostgreSQLProvider(connStr, "Readings", "Spans");
+ await provider.ApplyProvisionAsync();
+
+ await provider.ApplyChangesAsync(OldClientReadingChangeSet());
+
+ var applied = await ReadReadingFromPostgreSQL(connStr, 1);
+ applied.ShouldNotBeNull();
+ applied.StartMonth.ShouldBe((byte)3);
+ applied.EndMonth.ShouldBe((byte)250);
+ applied.Zone.ShouldBe((short)-5);
+ applied.CountTime.ShouldBe(new TimeSpan(14, 30, 0));
+ applied.Duration.ShouldBe(MicroDuration);
+ applied.Weight.ShouldBe(12.345m);
+ applied.Recorded.ShouldBe(MicroRecorded);
+ }
+ finally
+ {
+ await DropPostgreSQLDatabase(dbName);
+ }
+ }
+
+ private static SyncChangeSet OldClientReadingChangeSet()
+ => new(
+ new SyncAnchor(Guid.NewGuid(), 1),
+ SyncAnchor.Null,
+ new[]
+ {
+ RawItem("Readings", ChangeType.Insert, new Dictionary
+ {
+ ["Id"] = Raw(SyncItemValueType.Int64, 1L),
+ ["Name"] = Raw(SyncItemValueType.String, "old client"),
+ ["StartMonth"] = Raw(SyncItemValueType.Int64, 3L),
+ ["EndMonth"] = Raw(SyncItemValueType.Int64, 250L),
+ ["Zone"] = Raw(SyncItemValueType.Int64, -5L),
+ ["CountTime"] = Raw(SyncItemValueType.String, "14:30:00"),
+ ["Duration"] = Raw(SyncItemValueType.String, MicroDuration.ToString("c", CultureInfo.InvariantCulture)),
+ ["Weight"] = Raw(SyncItemValueType.Decimal, 12.345m),
+ ["Recorded"] = Raw(SyncItemValueType.DateTime, MicroRecorded)
+ })
+ });
+
+ #endregion
+}
diff --git a/src/CoreSync.Tests/ColumnTypeMappingTests.cs b/src/CoreSync.Tests/ColumnTypeMappingTests.cs
new file mode 100644
index 0000000..7224bd5
--- /dev/null
+++ b/src/CoreSync.Tests/ColumnTypeMappingTests.cs
@@ -0,0 +1,1215 @@
+using CoreSync.Http;
+using CoreSync.Sqlite;
+using CoreSync.SqlServer;
+using CoreSync.SqlServerCT;
+using Microsoft.Data.SqlClient;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Shouldly;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace CoreSync.Tests;
+
+///
+/// Covers the column types that have no dedicated and used to make
+/// synchronization fail outright with
+/// NotSupportedException: Type of value ('System.Byte') is not supported for synchronization
+/// as soon as a synchronized table contained a SQL Server tinyint or time column:
+///
+/// a tinyint-like column (SQL Server/MySql tinyint) is read as a
+/// // and must travel as an
+/// , then be narrowed back when applied;
+/// a time-like column (SQL Server/MySql time, PostgreSQL time and
+/// interval) is read as a and must travel as an invariant "c"
+/// formatted - the very format both Microsoft.Data.Sqlite and
+/// EF Core use to store a - then be parsed back;
+/// no new member may appear, or a peer running an
+/// older version of the library would throw on the payload.
+///
+/// The round trips also carry smallint, decimal, uniqueidentifier and
+/// datetime2 columns, both null and non-null, so that the normalization cannot regress the
+/// types that already worked. The MySql and PostgreSQL halves live in
+/// ColumnTypeMappingTests.MySqlPostgreSQL.cs.
+///
+[TestClass]
+public partial class ColumnTypeMappingTests
+{
+ private static string SqlServerConnectionString => Environment.GetEnvironmentVariable("CORE-SYNC_CONNECTION_STRING") ??
+ "Server=localhost;User Id=sa;Password=CoreSync_Test123!;TrustServerCertificate=True";
+
+ private static string SqlServerConnectionStringFor(string databaseName)
+ => new SqlConnectionStringBuilder(SqlServerConnectionString) { InitialCatalog = databaseName }.ConnectionString;
+
+ #region Model
+
+ ///
+ /// Mirrors the raw DDL below. Property types are what the mobile side of a real deployment declares,
+ /// so the SQLite provider reads every column through its typed reader path.
+ ///
+ public class Measurement
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public byte StartMonth { get; set; }
+ public byte? EndMonth { get; set; }
+ public short Zone { get; set; }
+ public TimeSpan CountTime { get; set; }
+ public TimeSpan? Duration { get; set; }
+ public decimal Weight { get; set; }
+ public Guid ExternalId { get; set; }
+ public DateTime Recorded { get; set; }
+ }
+
+ ///
+ /// One nullable column per CLR type the SQLite provider now reads through a typed getter, so that
+ /// extending typed reads to nullable columns cannot regress a type that used to go through
+ /// GetValue().
+ ///
+ public class NullableProbe
+ {
+ public int Id { get; set; }
+ public DateTime? Moment { get; set; }
+ public decimal? Amount { get; set; }
+ public Guid? Reference { get; set; }
+ public bool? Flag { get; set; }
+ public double? Ratio { get; set; }
+ public float? Single { get; set; }
+ public int? Counter { get; set; }
+ public long? Big { get; set; }
+ public short? Tiny { get; set; }
+ public byte? Small { get; set; }
+ public TimeSpan? Elapsed { get; set; }
+ public string? Label { get; set; }
+ }
+
+ private sealed class MeasurementDbContext : DbContext
+ {
+ private readonly string _connectionString;
+
+ public MeasurementDbContext(string connectionString) => _connectionString = connectionString;
+
+ public DbSet Measurements => Set();
+ public DbSet NullableProbes => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseSqlite(_connectionString);
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Measurements");
+ entity.HasKey(_ => _.Id);
+ entity.Property(_ => _.Id).ValueGeneratedNever();
+ entity.Property(_ => _.Weight).HasColumnType("TEXT");
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("NullableProbes");
+ entity.HasKey(_ => _.Id);
+ entity.Property(_ => _.Id).ValueGeneratedNever();
+ entity.Property(_ => _.Amount).HasColumnType("TEXT");
+ });
+ }
+ }
+
+ private const string SqlServerDdl = @"
+ CREATE TABLE [dbo].[Measurements] (
+ [Id] INT NOT NULL PRIMARY KEY,
+ [Name] NVARCHAR(100) NOT NULL,
+ [StartMonth] TINYINT NOT NULL,
+ [EndMonth] TINYINT NULL,
+ [Zone] SMALLINT NOT NULL,
+ [CountTime] TIME(0) NOT NULL,
+ [Duration] TIME(7) NULL,
+ [Weight] DECIMAL(10,3) NOT NULL,
+ [ExternalId] UNIQUEIDENTIFIER NOT NULL,
+ [Recorded] DATETIME2 NOT NULL
+ );
+ CREATE TABLE [dbo].[NullableProbes] (
+ [Id] INT NOT NULL PRIMARY KEY,
+ [Moment] DATETIME2 NULL,
+ [Amount] DECIMAL(18,4) NULL,
+ [Reference] UNIQUEIDENTIFIER NULL,
+ [Flag] BIT NULL,
+ [Ratio] FLOAT NULL,
+ [Single] REAL NULL,
+ [Counter] INT NULL,
+ [Big] BIGINT NULL,
+ [Tiny] SMALLINT NULL,
+ [Small] TINYINT NULL,
+ [Elapsed] TIME(7) NULL,
+ [Label] NVARCHAR(50) NULL
+ );";
+
+ // Declared types are the ones EF Core's SQLite provider generates for the model above.
+ private const string SqliteDdl = @"
+ CREATE TABLE [Measurements] (
+ [Id] INTEGER NOT NULL PRIMARY KEY,
+ [Name] TEXT NOT NULL,
+ [StartMonth] INTEGER NOT NULL,
+ [EndMonth] INTEGER NULL,
+ [Zone] INTEGER NOT NULL,
+ [CountTime] TEXT NOT NULL,
+ [Duration] TEXT NULL,
+ [Weight] TEXT NOT NULL,
+ [ExternalId] TEXT NOT NULL,
+ [Recorded] TEXT NOT NULL
+ );
+ CREATE TABLE [NullableProbes] (
+ [Id] INTEGER NOT NULL PRIMARY KEY,
+ [Moment] TEXT NULL,
+ [Amount] TEXT NULL,
+ [Reference] TEXT NULL,
+ [Flag] INTEGER NULL,
+ [Ratio] REAL NULL,
+ [Single] REAL NULL,
+ [Counter] INTEGER NULL,
+ [Big] INTEGER NULL,
+ [Tiny] INTEGER NULL,
+ [Small] INTEGER NULL,
+ [Elapsed] TEXT NULL,
+ [Label] TEXT NULL
+ );";
+
+ #endregion
+
+ #region Shared values
+
+ private static readonly Guid DownloadedGuid = new("0f8fad5b-d9cb-469f-a165-70867728950e");
+ private static readonly Guid UploadedGuid = new("7c9e6679-7425-40de-944b-e07fc1f90ae7");
+
+ /// 01:02:03.4567890 - seven fractional digits, the full resolution of a TimeSpan tick.
+ private static TimeSpan TickPreciseDuration => new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890));
+
+ /// 12:34:56.7891234 - the value uploaded back from SQLite.
+ private static TimeSpan UploadedDuration => new TimeSpan(0, 12, 34, 56).Add(TimeSpan.FromTicks(7891234));
+
+ private static DateTime DownloadedRecorded => new DateTime(2026, 9, 15, 21, 21, 33).AddTicks(1234567);
+ private static DateTime UploadedRecorded => new DateTime(2026, 2, 3, 4, 5, 6).AddTicks(7654321);
+
+ #endregion
+
+ #region Helpers - SQL Server
+
+ private static async Task ExecuteSqlServerNonQuery(string connectionString, string commandText)
+ {
+ using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = commandText;
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private static async Task