From 207148764a30a203331cc36512efd19271ee7862 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Sat, 21 Mar 2026 11:31:39 -0500 Subject: [PATCH 1/2] Enhance data retrieval: add UUID handling in DataReader and optimize SQL generation for EXISTS queries --- .../src/DecentDB.AdoNet/DecentDBDataReader.cs | 23 ++++++-- .../Internal/DecentDBQuerySqlGenerator.cs | 16 ++++++ .../QueryTranslationTests.cs | 52 +++++++++++++++++++ .../tests/DecentDB.Tests/DataReaderTests.cs | 27 ++++++++++ 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs index d961872..df2a48f 100644 --- a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs +++ b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs @@ -51,6 +51,21 @@ public override int RecordsAffected } } + private string GetStringValue(int ordinal) + { + var type = _statement.ColumnType(ordinal); + if (type == 5) + { + var bytes = _statement.GetBlob(ordinal); + if (bytes.Length == 16) + { + return new Guid(bytes).ToString(); + } + } + + return _statement.GetText(ordinal); + } + public override object this[int ordinal] => GetValue(ordinal); public override object this[string name] => GetValue(GetOrdinal(name)); @@ -127,7 +142,7 @@ public override T GetFieldValue(int ordinal) object boxed; if (nonNullableType == typeof(string)) { - boxed = _statement.GetText(ordinal); + boxed = GetStringValue(ordinal); } else if (nonNullableType == typeof(short)) { @@ -231,7 +246,7 @@ public override double GetDouble(int ordinal) public override string GetString(int ordinal) { - return _statement.GetText(ordinal); + return GetStringValue(ordinal); } public override bool GetBoolean(int ordinal) @@ -255,7 +270,7 @@ public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) { - var str = _statement.GetText(ordinal); + var str = GetStringValue(ordinal); if (buffer == null) { return str.Length; @@ -269,7 +284,7 @@ public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int public override char GetChar(int ordinal) { - var str = _statement.GetText(ordinal); + var str = GetStringValue(ordinal); return str.Length > 0 ? str[0] : '\0'; } diff --git a/bindings/dotnet/src/DecentDB.EntityFrameworkCore/Query/Internal/DecentDBQuerySqlGenerator.cs b/bindings/dotnet/src/DecentDB.EntityFrameworkCore/Query/Internal/DecentDBQuerySqlGenerator.cs index 3fca5c4..e82750b 100644 --- a/bindings/dotnet/src/DecentDB.EntityFrameworkCore/Query/Internal/DecentDBQuerySqlGenerator.cs +++ b/bindings/dotnet/src/DecentDB.EntityFrameworkCore/Query/Internal/DecentDBQuerySqlGenerator.cs @@ -45,6 +45,22 @@ protected override void GenerateLimitOffset(SelectExpression selectExpression) } } + protected override void GenerateExists(ExistsExpression existsExpression, bool negated) + { + if (existsExpression.Subquery.Limit is null + && existsExpression.Subquery.Offset is null) + { + Sql.Append(negated ? "NOT EXISTS (" : "EXISTS ("); + Sql.AppendLine(); + Visit(existsExpression.Subquery); + Sql.AppendLine().Append("LIMIT 1"); + Sql.AppendLine().Append(")"); + return; + } + + base.GenerateExists(existsExpression, negated); + } + protected override void GenerateIn(InExpression inExpression, bool negated) { if (inExpression.Values is { Count: > MaxInListValues }) diff --git a/bindings/dotnet/tests/DecentDB.EntityFrameworkCore.Tests/QueryTranslationTests.cs b/bindings/dotnet/tests/DecentDB.EntityFrameworkCore.Tests/QueryTranslationTests.cs index 6c3bddf..c4fc338 100644 --- a/bindings/dotnet/tests/DecentDB.EntityFrameworkCore.Tests/QueryTranslationTests.cs +++ b/bindings/dotnet/tests/DecentDB.EntityFrameworkCore.Tests/QueryTranslationTests.cs @@ -101,6 +101,37 @@ public void LikeEscaping_EscapesLiteralWildcards() Assert.Contains("\\_", sql, StringComparison.Ordinal); } + [Fact] + public void Any_EmitsExistsWithLimitOne() + { + SeedData(); + + using var context = CreateContext(); + var connection = Assert.IsType(context.Database.GetDbConnection()); + + var (result, sql) = CaptureExecutedSql(connection, () => context.Items.Any()); + + Assert.True(result); + Assert.Contains("EXISTS", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT 1", sql, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AnyWithPredicate_EmitsExistsWithLimitOne() + { + SeedData(); + + using var context = CreateContext(); + var connection = Assert.IsType(context.Database.GetDbConnection()); + + var (result, sql) = CaptureExecutedSql(connection, () => context.Items.Any(x => x.Id >= 5)); + + Assert.True(result); + Assert.Contains("EXISTS", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT 1", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + } + private AppDbContext CreateContext() { var optionsBuilder = new DbContextOptionsBuilder(); @@ -140,6 +171,27 @@ private static void TryDelete(string path) } } + private static (T Result, string Sql) CaptureExecutedSql(DecentDBConnection connection, Func operation) + { + string? sql = null; + + void HandleSqlExecuted(object? _, SqlExecutedEventArgs args) + { + sql = args.Sql; + } + + connection.SqlExecuted += HandleSqlExecuted; + try + { + var result = operation(); + return (result, Assert.IsType(sql)); + } + finally + { + connection.SqlExecuted -= HandleSqlExecuted; + } + } + private sealed class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) diff --git a/bindings/dotnet/tests/DecentDB.Tests/DataReaderTests.cs b/bindings/dotnet/tests/DecentDB.Tests/DataReaderTests.cs index 5b1eb22..40ae9a6 100644 --- a/bindings/dotnet/tests/DecentDB.Tests/DataReaderTests.cs +++ b/bindings/dotnet/tests/DecentDB.Tests/DataReaderTests.cs @@ -169,6 +169,33 @@ public void GetFieldValue() Assert.Equal(42L, reader.GetFieldValue(2)); } + [Fact] + public void GetString_FromUuidColumn_ReturnsCanonicalGuid() + { + using var conn = new DecentDBConnection($"Data Source={_dbPath}"); + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE TABLE uuid_string_test (id INTEGER PRIMARY KEY, value UUID)"; + cmd.ExecuteNonQuery(); + + var expected = Guid.NewGuid(); + cmd.CommandText = "INSERT INTO uuid_string_test (id, value) VALUES (1, @value)"; + var parameter = cmd.CreateParameter(); + parameter.ParameterName = "@value"; + parameter.Value = expected; + cmd.Parameters.Add(parameter); + cmd.ExecuteNonQuery(); + + cmd.CommandText = "SELECT value FROM uuid_string_test WHERE id = 1"; + cmd.Parameters.Clear(); + using var reader = cmd.ExecuteReader(); + + Assert.True(reader.Read()); + Assert.Equal(expected.ToString(), reader.GetString(0)); + Assert.Equal(expected.ToString(), reader.GetFieldValue(0)); + } + [Fact] public void IndexerAccess() { From e640dcb759c3618577f1c30484ce61cf1de31b84 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Sat, 21 Mar 2026 17:36:24 -0500 Subject: [PATCH 2/2] feat: Enhance DateTime handling in DecentDB ADO.NET bindings - Introduced FromUnixEpochMicroseconds method for better clarity in converting microseconds to DateTime. - Updated GetInt64Value method to handle TIMESTAMP columns correctly. - Modified DataReader to return DateTime for TIMESTAMP columns using the new conversion method. - Added tests for TIMESTAMP columns to ensure correct metadata reporting and round-trip functionality. - Implemented DecentDBMaintenance class for database file maintenance, including an atomic vacuum operation. - Created DecentDB.NativeAssets.props for managing native asset paths across platforms. - Updated project files to include native assets conditionally based on build outputs. - Added comprehensive tests for maintenance operations to validate vacuum functionality. - Documented the changesets implementation plan for better versioning and release management. --- .../DecentDB.AdoNet/DecentDB.AdoNet.csproj | 8 +- .../src/DecentDB.AdoNet/DecentDBCommand.cs | 118 +++++++- .../src/DecentDB.AdoNet/DecentDBDataReader.cs | 28 +- .../DecentDB.AdoNet/DecentDBMaintenance.cs | 93 ++++++ .../Internal/DecentDBTypeMappingSource.cs | 2 +- .../DecentDB.MicroOrm.csproj | 8 +- .../DecentDB.Native/DecentDB.Native.csproj | 25 +- .../dotnet/src/DecentDB.NativeAssets.props | 70 +++++ .../TypeMappingTests.cs | 85 ++++++ .../tests/DecentDB.Tests/AdoNetLayerTests.cs | 48 +++- .../tests/DecentDB.Tests/DataReaderTests.cs | 35 +++ .../tests/DecentDB.Tests/MaintenanceTests.cs | 91 ++++++ design/CHANGESETS_IMPLEMENTATION_PLAN.md | 270 ++++++++++++++++++ 13 files changed, 862 insertions(+), 19 deletions(-) create mode 100644 bindings/dotnet/src/DecentDB.AdoNet/DecentDBMaintenance.cs create mode 100644 bindings/dotnet/src/DecentDB.NativeAssets.props create mode 100644 bindings/dotnet/tests/DecentDB.Tests/MaintenanceTests.cs create mode 100644 design/CHANGESETS_IMPLEMENTATION_PLAN.md diff --git a/bindings/dotnet/src/DecentDB.AdoNet/DecentDB.AdoNet.csproj b/bindings/dotnet/src/DecentDB.AdoNet/DecentDB.AdoNet.csproj index 10ac5ab..ea5b37f 100644 --- a/bindings/dotnet/src/DecentDB.AdoNet/DecentDB.AdoNet.csproj +++ b/bindings/dotnet/src/DecentDB.AdoNet/DecentDB.AdoNet.csproj @@ -1,4 +1,6 @@ + + net10.0 enable @@ -32,9 +34,9 @@ - - - + + + diff --git a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBCommand.cs b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBCommand.cs index 4373c1c..b6604a0 100644 --- a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBCommand.cs +++ b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBCommand.cs @@ -20,6 +20,9 @@ public sealed class DecentDBCommand : DbCommand private readonly DecentDBParameterCollection _parameterCollection; private DecentDBTransaction? _transaction; private PreparedStatement? _statement; + private PreparedStatement? _preparedStatement; + private string? _preparedSql; + private Native.DecentDB? _preparedDb; private bool _disposed; public DecentDBCommand() @@ -55,6 +58,7 @@ public override string CommandText { throw new InvalidOperationException("Cannot change CommandText while command is executing"); } + InvalidatePreparedStatement(); _commandText = value ?? string.Empty; } } @@ -96,6 +100,7 @@ protected override DbConnection? DbConnection { throw new InvalidOperationException("Cannot change connection while command is executing"); } + InvalidatePreparedStatement(); _connection = null; return; } @@ -108,6 +113,10 @@ protected override DbConnection? DbConnection { throw new InvalidOperationException("Cannot change connection while command is executing"); } + if (!ReferenceEquals(_connection, conn)) + { + InvalidatePreparedStatement(); + } _connection = conn; } } @@ -142,9 +151,7 @@ public override int ExecuteNonQuery() var statements = SqlStatementSplitter.Split(_commandText); if (statements.Count <= 1) { - using var reader = ExecuteDbDataReader(CommandBehavior.Default); - while (reader.Read()) { } - return reader.RecordsAffected; + return ExecuteSingleNonQuery(); } // Multi-statement: execute each individually, sum affected rows @@ -292,6 +299,18 @@ public override void Prepare() { throw new InvalidOperationException("Connection must be open to prepare command"); } + + var statements = SqlStatementSplitter.Split(_commandText); + if (statements.Count != 1) + { + return; + } + + var (sql, paramMap) = SqlParameterRewriter.Rewrite(_commandText, _parameters); + SqlParameterRewriter.ClampOffsetParameters(sql, paramMap); + sql = SqlParameterRewriter.StripUpdateDeleteAlias(sql); + + EnsurePreparedStatement(sql, resetForExecution: false); } internal static void BindParameter(PreparedStatement stmt, int index1Based, DbParameter parameter) @@ -389,6 +408,98 @@ internal void FinalizeStatement() _statement = null; } + private int ExecuteSingleNonQuery() + { + if (_connection == null) + { + throw new InvalidOperationException("Command has no connection"); + } + + var (sql, paramMap) = SqlParameterRewriter.Rewrite(_commandText, _parameters); + SqlParameterRewriter.ClampOffsetParameters(sql, paramMap); + sql = SqlParameterRewriter.StripUpdateDeleteAlias(sql); + + var observation = _connection.TryStartSqlObservation(sql, SnapshotParameters(paramMap)); + + try + { + var stmt = EnsurePreparedStatement(sql, resetForExecution: true); + + foreach (var kvp in paramMap) + { + BindParameter(stmt, kvp.Key, kvp.Value); + } + + var stepResult = stmt.Step(); + while (stepResult == 1) + { + stepResult = stmt.Step(); + } + + if (stepResult < 0) + { + var ex = new DecentDBException(stmt.RowsAffected > 0 ? (int)stmt.RowsAffected : stepResult, + _connection.GetNativeDb().LastErrorMessage, sql); + InvalidatePreparedStatement(); + throw ex; + } + + if (observation != null) + { + _connection.CompleteSqlObservation(observation, stmt.RowsAffected, exception: null); + } + + return (int)stmt.RowsAffected; + } + catch (Exception ex) + { + InvalidatePreparedStatement(); + + if (observation != null) + { + _connection.CompleteSqlObservation(observation, rowsAffected: 0, ex); + } + + throw; + } + } + + private PreparedStatement EnsurePreparedStatement(string sql, bool resetForExecution) + { + if (_connection == null) + { + throw new InvalidOperationException("Command has no connection"); + } + + var nativeDb = _connection.GetNativeDb(); + if (_preparedStatement != null && + ReferenceEquals(_preparedDb, nativeDb) && + string.Equals(_preparedSql, sql, StringComparison.Ordinal)) + { + if (resetForExecution) + { + _preparedStatement.Reset().ClearBindings(); + } + + return _preparedStatement; + } + + InvalidatePreparedStatement(); + + _preparedStatement = nativeDb.Prepare(sql); + _preparedSql = sql; + _preparedDb = nativeDb; + return _preparedStatement; + } + + private void InvalidatePreparedStatement() + { + _preparedStatement?.Dispose(); + _preparedStatement = null; + _preparedSql = null; + _preparedDb = null; + } + protected override void Dispose(bool disposing) { if (_disposed) return; @@ -396,6 +507,7 @@ protected override void Dispose(bool disposing) if (disposing) { FinalizeStatement(); + InvalidatePreparedStatement(); } _disposed = true; diff --git a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs index df2a48f..800bf9d 100644 --- a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs +++ b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs @@ -66,6 +66,18 @@ private string GetStringValue(int ordinal) return _statement.GetText(ordinal); } + private static DateTime FromUnixEpochMicroseconds(long micros) + { + return new DateTime(micros * 10L + DateTime.UnixEpoch.Ticks, DateTimeKind.Utc); + } + + private long GetInt64Value(int ordinal) + { + return _statement.ColumnType(ordinal) == 17 + ? DecentDBNativeUnsafe.decentdb_column_datetime(_statement.Handle, ordinal) + : _statement.GetInt64(ordinal); + } + public override object this[int ordinal] => GetValue(ordinal); public override object this[string name] => GetValue(GetOrdinal(name)); @@ -87,6 +99,7 @@ public override string GetDataTypeName(int ordinal) 4 => "TEXT", 5 => "BLOB", 12 => "DECIMAL", + 17 => "TIMESTAMP", _ => "UNKNOWN" }; } @@ -103,6 +116,7 @@ public override Type GetFieldType(int ordinal) 4 => typeof(string), 5 => typeof(byte[]), 12 => typeof(decimal), + 17 => typeof(DateTime), _ => typeof(object) }; } @@ -123,7 +137,7 @@ public override object GetValue(int ordinal) 4 => _statement.GetText(ordinal), 5 => _statement.GetBlob(ordinal), 12 => _statement.GetDecimal(ordinal), - 17 => new DateTime(DecentDBNativeUnsafe.decentdb_column_datetime(_statement.Handle, ordinal) * 10L + DateTime.UnixEpoch.Ticks, DateTimeKind.Utc), + 17 => FromUnixEpochMicroseconds(DecentDBNativeUnsafe.decentdb_column_datetime(_statement.Handle, ordinal)), _ => DBNull.Value }; } @@ -154,7 +168,7 @@ public override T GetFieldValue(int ordinal) } else if (nonNullableType == typeof(long)) { - boxed = _statement.GetInt64(ordinal); + boxed = GetInt64Value(ordinal); } else if (nonNullableType == typeof(bool)) { @@ -175,12 +189,12 @@ public override T GetFieldValue(int ordinal) else if (nonNullableType == typeof(DateTime)) { var micros = DecentDBNativeUnsafe.decentdb_column_datetime(_statement.Handle, ordinal); - boxed = new DateTime(micros * 10L + DateTime.UnixEpoch.Ticks, DateTimeKind.Utc); + boxed = FromUnixEpochMicroseconds(micros); } else if (nonNullableType == typeof(DateTimeOffset)) { var micros = DecentDBNativeUnsafe.decentdb_column_datetime(_statement.Handle, ordinal); - boxed = new DateTimeOffset(micros * 10L + DateTime.UnixEpoch.Ticks, TimeSpan.Zero); + boxed = new DateTimeOffset(FromUnixEpochMicroseconds(micros), TimeSpan.Zero); } else if (nonNullableType == typeof(DateOnly)) { @@ -236,7 +250,7 @@ public override int GetInt32(int ordinal) public override long GetInt64(int ordinal) { - return _statement.GetInt64(ordinal); + return GetInt64Value(ordinal); } public override double GetDouble(int ordinal) @@ -311,8 +325,8 @@ public override Guid GetGuid(int ordinal) public override DateTime GetDateTime(int ordinal) { - var ms = _statement.GetInt64(ordinal); - return DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime; + var micros = DecentDBNativeUnsafe.decentdb_column_datetime(_statement.Handle, ordinal); + return FromUnixEpochMicroseconds(micros); } public override decimal GetDecimal(int ordinal) diff --git a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBMaintenance.cs b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBMaintenance.cs new file mode 100644 index 0000000..963ccd4 --- /dev/null +++ b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBMaintenance.cs @@ -0,0 +1,93 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace DecentDB.AdoNet +{ + /// + /// Provides maintenance utilities for DecentDB database files. + /// + public static class DecentDBMaintenance + { + /// + /// Spawns the DecentDB CLI to perform an offline vacuum. + /// Performs an atomic swap of the database file if successful. + /// Ensure no connections are open to the database file before running. + /// + /// The path to the DecentDB database file. + /// The path to the DecentDB executable. Defaults to "decentdb" assuming it is in the system PATH. + /// If true, renames the original database file with a .bak extension instead of deleting it. + /// A token to cancel the operation. + /// True if vacuum was successful, false if the database file didn't exist. + public static async Task VacuumAtomicAsync( + string databasePath, + string cliExecutablePath = "decentdb", + bool createBackup = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(databasePath)) + throw new ArgumentException("Database path cannot be null or empty.", nameof(databasePath)); + + if (!File.Exists(databasePath)) + return false; + + var dbFileInfo = new FileInfo(databasePath); + var tempPath = dbFileInfo.FullName + ".vacuum_tmp"; + var backupPath = dbFileInfo.FullName + ".bak"; + + // Ensure previous interrupted temp files are removed + if (File.Exists(tempPath)) + File.Delete(tempPath); + + var startInfo = new ProcessStartInfo + { + FileName = cliExecutablePath, + Arguments = $"vacuum --db \"{dbFileInfo.FullName}\" --output \"{tempPath}\" --overwrite", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + + try + { + process.Start(); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync().ConfigureAwait(false); + var stdout = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + if (File.Exists(tempPath)) + File.Delete(tempPath); + throw new InvalidOperationException($"Vacuum failed with exit code {process.ExitCode}. Error: {error} | Stdout: {stdout}"); + } + + // Atomic swap + if (createBackup) + { + if (File.Exists(backupPath)) + File.Delete(backupPath); + File.Move(dbFileInfo.FullName, backupPath); + } + else + { + File.Delete(dbFileInfo.FullName); + } + + File.Move(tempPath, dbFileInfo.FullName); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not InvalidOperationException) + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + throw new InvalidOperationException($"An error occurred during vacuum: {ex.Message}", ex); + } + } + } +} diff --git a/bindings/dotnet/src/DecentDB.EntityFrameworkCore/Storage/Internal/DecentDBTypeMappingSource.cs b/bindings/dotnet/src/DecentDB.EntityFrameworkCore/Storage/Internal/DecentDBTypeMappingSource.cs index fd7a3a7..e4c8970 100644 --- a/bindings/dotnet/src/DecentDB.EntityFrameworkCore/Storage/Internal/DecentDBTypeMappingSource.cs +++ b/bindings/dotnet/src/DecentDB.EntityFrameworkCore/Storage/Internal/DecentDBTypeMappingSource.cs @@ -28,7 +28,7 @@ public DecentDBTypeMappingSource( var blobMapping = new ByteArrayTypeMapping("BLOB", DbType.Binary); var timestampStorageMapping = new LongTypeMapping("TIMESTAMP", DbType.Int64); - // DateTime/DateTimeOffset stored as microseconds since Unix epoch UTC in TIMESTAMP columns. + // DateTime/DateTimeOffset values are stored as microseconds since Unix epoch UTC in TIMESTAMP columns. var dateTimeMapping = (RelationalTypeMapping)timestampStorageMapping.WithComposedConverter( new ValueConverter( value => (value.ToUniversalTime().Ticks - DateTime.UnixEpoch.Ticks) / 10L, diff --git a/bindings/dotnet/src/DecentDB.MicroOrm/DecentDB.MicroOrm.csproj b/bindings/dotnet/src/DecentDB.MicroOrm/DecentDB.MicroOrm.csproj index c36e8e5..15bee71 100644 --- a/bindings/dotnet/src/DecentDB.MicroOrm/DecentDB.MicroOrm.csproj +++ b/bindings/dotnet/src/DecentDB.MicroOrm/DecentDB.MicroOrm.csproj @@ -1,4 +1,6 @@ + + net10.0 enable @@ -30,9 +32,9 @@ - - - + + + diff --git a/bindings/dotnet/src/DecentDB.Native/DecentDB.Native.csproj b/bindings/dotnet/src/DecentDB.Native/DecentDB.Native.csproj index 056da1a..ff75eae 100644 --- a/bindings/dotnet/src/DecentDB.Native/DecentDB.Native.csproj +++ b/bindings/dotnet/src/DecentDB.Native/DecentDB.Native.csproj @@ -1,4 +1,6 @@ + + net10.0 enable @@ -11,6 +13,27 @@ false - + + + + libdecentdb.so + PreserveNewest + false + + + libdecentdb.dylib + PreserveNewest + false + + + decentdb.dll + PreserveNewest + false + + + diff --git a/bindings/dotnet/src/DecentDB.NativeAssets.props b/bindings/dotnet/src/DecentDB.NativeAssets.props new file mode 100644 index 0000000..0190b70 --- /dev/null +++ b/bindings/dotnet/src/DecentDB.NativeAssets.props @@ -0,0 +1,70 @@ + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../build/libdecentdb.so')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../build/libc_api.so')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)DecentDB.MicroOrm/runtimes/linux-x64/native/libdecentdb.so')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../build/libdecentdb.dylib')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../build/libc_api.dylib')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)DecentDB.MicroOrm/runtimes/osx-x64/native/libdecentdb.dylib')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../build/decentdb.dll')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../build/c_api.dll')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)DecentDB.MicroOrm/runtimes/win-x64/native/decentdb.dll')) + + + + + + + $(DecentDBLinuxBuildNativePrimary) + + + + + $(DecentDBLinuxBuildNativeFallback) + + + + + $(DecentDBLinuxPackagedNative) + + + + + + + + $(DecentDBMacBuildNativePrimary) + + + + + $(DecentDBMacBuildNativeFallback) + + + + + $(DecentDBMacPackagedNative) + + + + + + + + $(DecentDBWindowsBuildNativePrimary) + + + + + $(DecentDBWindowsBuildNativeFallback) + + + + + $(DecentDBWindowsPackagedNative) + + + + diff --git a/bindings/dotnet/tests/DecentDB.EntityFrameworkCore.Tests/TypeMappingTests.cs b/bindings/dotnet/tests/DecentDB.EntityFrameworkCore.Tests/TypeMappingTests.cs index 099771b..0c49815 100644 --- a/bindings/dotnet/tests/DecentDB.EntityFrameworkCore.Tests/TypeMappingTests.cs +++ b/bindings/dotnet/tests/DecentDB.EntityFrameworkCore.Tests/TypeMappingTests.cs @@ -49,6 +49,8 @@ public void TypeMappingSource_MapsStoreTypeAliasesUsedBySchemaDiscovery() Assert.Equal("INTEGER", mappingSource.FindMapping("INT64")!.StoreType); Assert.Equal("INTEGER", mappingSource.FindMapping("INT32")!.StoreType); Assert.Equal("REAL", mappingSource.FindMapping("FLOAT64")!.StoreType); + Assert.Equal("TIMESTAMP", mappingSource.FindMapping("TIMESTAMP")!.StoreType); + Assert.Equal("TIMESTAMP", mappingSource.FindMapping("DATETIME")!.StoreType); } [Fact] @@ -141,6 +143,70 @@ public void SqlGenerationHelper_QuotesIdentifiers() Assert.Equal("\"my\"\"table\"", sqlHelper.DelimitIdentifier("my\"table")); } + [Fact] + public void EnsureCreated_WithDateTimeColumns_UsesTimestampSchema() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"test_ef_datetime_schema_{Guid.NewGuid():N}.ddb"); + + try + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseDecentDB($"Data Source={dbPath}"); + + using var context = new DateTimeSchemaContext(optionsBuilder.Options); + var createScript = context.Database.GenerateCreateScript(); + + Assert.Contains("\"OccurredAt\" TIMESTAMP NOT NULL", createScript); + Assert.Contains("\"ProcessedAt\" TIMESTAMP NULL", createScript); + + context.Database.EnsureCreated(); + } + finally + { + TryDelete(dbPath); + TryDelete(dbPath + "-wal"); + } + } + + [Fact] + public void DateTimeColumns_RoundTripThroughEntityFramework() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"test_ef_datetime_roundtrip_{Guid.NewGuid():N}.ddb"); + + try + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseDecentDB($"Data Source={dbPath}"); + + var occurredAt = new DateTime(2020, 3, 4, 0, 0, 0, DateTimeKind.Utc); + var processedAt = new DateTimeOffset(2020, 5, 6, 7, 8, 9, TimeSpan.Zero); + + using (var writeContext = new DateTimeSchemaContext(optionsBuilder.Options)) + { + writeContext.Database.EnsureCreated(); + writeContext.Events.Add(new DateTimeEntity + { + OccurredAt = occurredAt, + ProcessedAt = processedAt + }); + + writeContext.SaveChanges(); + } + + using (var readContext = new DateTimeSchemaContext(optionsBuilder.Options)) + { + var entity = readContext.Events.Single(); + Assert.Equal(occurredAt, entity.OccurredAt); + Assert.Equal(processedAt, entity.ProcessedAt); + } + } + finally + { + TryDelete(dbPath); + TryDelete(dbPath + "-wal"); + } + } + private DbContext CreateContext() { var optionsBuilder = new DbContextOptionsBuilder(); @@ -166,4 +232,23 @@ public SmokeDbContext(DbContextOptions options) { } } + + private sealed class DateTimeSchemaContext : DbContext + { + public DateTimeSchemaContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Events => Set(); + } + + private sealed class DateTimeEntity + { + public int Id { get; set; } + + public DateTime OccurredAt { get; set; } + + public DateTimeOffset? ProcessedAt { get; set; } + } } diff --git a/bindings/dotnet/tests/DecentDB.Tests/AdoNetLayerTests.cs b/bindings/dotnet/tests/DecentDB.Tests/AdoNetLayerTests.cs index 6db25cb..21b3eea 100644 --- a/bindings/dotnet/tests/DecentDB.Tests/AdoNetLayerTests.cs +++ b/bindings/dotnet/tests/DecentDB.Tests/AdoNetLayerTests.cs @@ -3,6 +3,7 @@ using System.Data.Common; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -448,6 +449,51 @@ public async Task DecentDBCommand_ExecuteScalarAsync() var result = await cmd.ExecuteScalarAsync(CancellationToken.None); Assert.Equal(42L, result); } + + [Fact] + public void DecentDBCommand_Prepare_ReusesSingleStatementForRepeatedNonQueryExecution() + { + using var conn = new DecentDBConnection($"Data Source={_dbPath}"); + conn.Open(); + + using (var createCmd = conn.CreateCommand()) + { + createCmd.CommandText = "CREATE TABLE prepared_reuse (id INTEGER PRIMARY KEY, name TEXT)"; + createCmd.ExecuteNonQuery(); + } + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO prepared_reuse (id, name) VALUES (@p0, @p1)"; + + var idParam = cmd.CreateParameter(); + idParam.ParameterName = "@p0"; + cmd.Parameters.Add(idParam); + + var nameParam = cmd.CreateParameter(); + nameParam.ParameterName = "@p1"; + cmd.Parameters.Add(nameParam); + + var preparedField = typeof(DecentDBCommand).GetField("_preparedStatement", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(preparedField); + + cmd.Prepare(); + var preparedStatement = preparedField!.GetValue(cmd); + Assert.NotNull(preparedStatement); + + idParam.Value = 1; + nameParam.Value = "alpha"; + Assert.Equal(1, cmd.ExecuteNonQuery()); + Assert.Same(preparedStatement, preparedField.GetValue(cmd)); + + idParam.Value = 2; + nameParam.Value = "beta"; + Assert.Equal(1, cmd.ExecuteNonQuery()); + Assert.Same(preparedStatement, preparedField.GetValue(cmd)); + + cmd.CommandText = "SELECT COUNT(*) FROM prepared_reuse"; + Assert.Null(preparedField.GetValue(cmd)); + Assert.Equal(2L, cmd.ExecuteScalar()); + } // ───── GetSchema tests ───── @@ -604,4 +650,4 @@ public override void Open() { } protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => null!; protected override DbCommand CreateDbCommand() => null!; } -} \ No newline at end of file +} diff --git a/bindings/dotnet/tests/DecentDB.Tests/DataReaderTests.cs b/bindings/dotnet/tests/DecentDB.Tests/DataReaderTests.cs index 40ae9a6..8bfa9bb 100644 --- a/bindings/dotnet/tests/DecentDB.Tests/DataReaderTests.cs +++ b/bindings/dotnet/tests/DecentDB.Tests/DataReaderTests.cs @@ -129,6 +129,41 @@ public void GetFieldType() Assert.Equal(typeof(bool), reader.GetFieldType(3)); } + [Fact] + public void TimestampColumns_ReportMetadataAndRoundTripCorrectly() + { + using var conn = new DecentDBConnection($"Data Source={_dbPath}"); + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE TABLE timestamp_test (occurred_at TIMESTAMP)"; + cmd.ExecuteNonQuery(); + + var expected = new DateTime(2020, 3, 4, 0, 0, 0, DateTimeKind.Utc); + var expectedMicros = (expected.Ticks - DateTime.UnixEpoch.Ticks) / 10L; + + cmd.CommandText = "INSERT INTO timestamp_test (occurred_at) VALUES (@occurredAt)"; + cmd.Parameters.Clear(); + var parameter = cmd.CreateParameter(); + parameter.ParameterName = "@occurredAt"; + parameter.Value = expected; + cmd.Parameters.Add(parameter); + cmd.ExecuteNonQuery(); + + cmd.CommandText = "SELECT occurred_at FROM timestamp_test"; + cmd.Parameters.Clear(); + using var reader = cmd.ExecuteReader(); + + Assert.True(reader.Read()); + Assert.Equal("TIMESTAMP", reader.GetDataTypeName(0)); + Assert.Equal(typeof(DateTime), reader.GetFieldType(0)); + Assert.Equal(expectedMicros, reader.GetInt64(0)); + Assert.Equal(expectedMicros, reader.GetFieldValue(0)); + Assert.Equal(expected, reader.GetDateTime(0)); + Assert.Equal(expected, reader.GetFieldValue(0)); + Assert.Equal(expected, reader.GetValue(0)); + } + [Fact] public void GetOrdinal() { diff --git a/bindings/dotnet/tests/DecentDB.Tests/MaintenanceTests.cs b/bindings/dotnet/tests/DecentDB.Tests/MaintenanceTests.cs new file mode 100644 index 0000000..caf0b67 --- /dev/null +++ b/bindings/dotnet/tests/DecentDB.Tests/MaintenanceTests.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Xunit; +using DecentDB.AdoNet; + +namespace DecentDB.Tests; + +public class MaintenanceTests +{ + private static string GetDecentDbCliPath() + { + // Try to find the decentdb executable going up the directory tree + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir != null) + { + var exePath = Path.Combine(dir.FullName, "decentdb"); + if (File.Exists(exePath)) + { + return exePath; + } + dir = dir.Parent; + } + + return "decentdb"; // Fallback to PATH + } + + [Fact] + public async Task VacuumAtomicAsync_NonExistentFile_ReturnsFalse() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"test_vacuum_none_{Guid.NewGuid():N}.ddb"); + + var result = await DecentDBMaintenance.VacuumAtomicAsync(dbPath, GetDecentDbCliPath()); + + Assert.False(result); + } + + [Fact] + public async Task VacuumAtomicAsync_ValidFile_PerformsVacuum() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"test_vacuum_{Guid.NewGuid():N}.ddb"); + var cliPath = GetDecentDbCliPath(); + + try + { + // Seed a database + using (var conn = new DecentDBConnection($"Data Source={dbPath}")) + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE TABLE VacuumTest (Id INTEGER PRIMARY KEY, Val TEXT);"; + cmd.ExecuteNonQuery(); + + cmd.CommandText = "INSERT INTO VacuumTest (Id, Val) VALUES (1, 'Hello');"; + cmd.ExecuteNonQuery(); + } + + // File should exist now + Assert.True(File.Exists(dbPath)); + + // Perform vacuum without backup + var result = await DecentDBMaintenance.VacuumAtomicAsync(dbPath, cliPath, createBackup: false); + + Assert.True(result); + Assert.True(File.Exists(dbPath)); + Assert.False(File.Exists(dbPath + ".bak")); + + // Perform vacuum with backup + var resultWithBackup = await DecentDBMaintenance.VacuumAtomicAsync(dbPath, cliPath, createBackup: true); + + Assert.True(resultWithBackup); + Assert.True(File.Exists(dbPath)); + Assert.True(File.Exists(dbPath + ".bak")); + + // Verify data is still intact + using (var conn = new DecentDBConnection($"Data Source={dbPath}")) + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM VacuumTest;"; + var count = Convert.ToInt64(cmd.ExecuteScalar()); + Assert.Equal(1L, count); + } + } + finally + { + if (File.Exists(dbPath)) File.Delete(dbPath); + if (File.Exists(dbPath + ".bak")) File.Delete(dbPath + ".bak"); + } + } +} diff --git a/design/CHANGESETS_IMPLEMENTATION_PLAN.md b/design/CHANGESETS_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ea17520 --- /dev/null +++ b/design/CHANGESETS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,270 @@ +# Changesets Implementation Plan for DecentDB + +This document outlines the strategy for migrating DecentDB's monolithic repository to an independently versioned "polyglot monorepo" using **Changesets**. + +## Why Changesets? + +As DecentDB grows, tying the core database version to the version of every language binding creates unnecessary noise. A patch to the `.NET` binding should not force a version bump for the `Python` binding or the `Nim` core. + +We want to keep the monorepo for developer velocity (atomic commits, unified testing) but decouple the release pipelines. Changesets allows us to: +1. Version the Core and Bindings independently. +2. Generate beautiful, component-specific changelogs (or one unified one). +3. Easily fix "oopsies" by editing simple Markdown files before release. +4. Avoid strict, rigid commit message requirements (like Conventional Commits). + +--- + +## 1. Architectural Overview + +### The "Dummy package.json" Strategy +Changesets was originally built for JavaScript (NPM) monorepos. To use it in a polyglot repository (Nim, C#, Python, Go), we will use a **"Publish-Free" Workspace Strategy**. + +We will place a minimalist `package.json` file in each component's directory. These files will not be published to NPM; they act purely as a **state tracker** for Changesets to know the current version of each component. + +```mermaid +graph TD + Root[Root Workspace: ./] --> Core[Core Engine: ./src] + Root --> Dotnet[Binding: ./bindings/dotnet] + Root --> Python[Binding: ./bindings/python] + Root --> Go[Binding: ./bindings/go] + + Core -- tracks version via --> P1(package.json: src) + Dotnet -- tracks version via --> P2(package.json: bindings/dotnet) + Python -- tracks version via --> P3(package.json: bindings/python) + Go -- tracks version via --> P4(package.json: bindings/go) +``` + +### The CI/CD Pipeline Flow +Instead of hardcoding versions in `.csproj`, `.nimble`, or `setup.py` files, those files will either remain static or be dynamically injected by GitHub Actions at build time based on the Git Tag created by Changesets. + +```mermaid +sequenceDiagram + actor Developer + participant Git as GitHub (main) + participant Action as Changeset Action + participant PR as Release PR + participant CI as Build & Deploy CI + + Developer->>Git: Push feature branch + .changeset/*.md + Developer->>Git: Merge PR to main + Git->>Action: Trigger on push to main + Action->>Action: Read .changeset/*.md files + Action->>PR: Create/Update "Version Packages" PR + Note over PR: Contains updated CHANGELOG.md
and bumped package.json files + Developer->>PR: Review and Merge to main + PR->>Action: Trigger on push to main + Action->>Git: Create Tags (e.g., bindings/dotnet@1.3.0) + Git->>CI: Trigger specific workflow based on Tag prefix + CI->>CI: Build artifact injecting version from Tag + CI->>CI: Publish to NuGet / PyPI / GitHub Releases +``` + +--- + +## 2. Step-by-Step Implementation Setup + +### Step 2.1: Initialize the Workspace +At the root of the repository, initialize a Node workspace to manage the Changesets CLI. + +1. Create a `package.json` in the root: +```json +{ + "name": "decentdb-monorepo", + "private": true, + "workspaces": [ + "src", + "bindings/*" + ], + "devDependencies": { + "@changesets/cli": "^2.27.1", + "@changesets/changelog-github": "^0.5.0" + } +} +``` + +2. Create a minimal `package.json` in `src/` and every binding folder *except Node*. +**CRITICAL: Tag Naming Convention** +To be compatible with Go Modules (which have strict tagging requirements), we will name our packages exactly as their folder paths appear from the root. + +Example for `bindings/dotnet/package.json`: +```json +{ + "name": "bindings/dotnet", + "version": "1.0.0", + "private": true +} +``` +Example for `src/package.json` (Core): +```json +{ + "name": "src", + "version": "1.0.0", + "private": true +} +``` + +#### Special Case: The Node.js Binding +You **do not** need to create a dummy `package.json` for `bindings/node`. You will use your *real* `package.json` for the Node binding. Changesets will treat it as a first-class citizen, physically updating the `"version"` field inside it during the release PR generation. + +*Important:* To ensure your Git tags are consistent and compatible across the monorepo, verify the `"name"` field in `bindings/node/package.json` is set to `bindings/node`. If you publish to NPM under a different name (e.g., `decentdb`), you will handle that mapping in your CI publish step. + +### Step 2.2: Configure Changesets +Run `npx changeset init` at the root. This creates a `.changeset/config.json` file. +Update it to look like this: + +```json +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": [ + "@changesets/changelog-github", + { "repo": "YOUR_ORG/decentdb" } + ], + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} +``` + +--- + +## 3. The Developer Workflow (Day-to-Day) + +When you finish a feature, fix a bug, or make any change that warrants a release, you do the following before you commit: + +1. Open your terminal in the root directory. +2. Run `npx changeset`. +3. An interactive prompt will ask you: + - **Which packages to bump?** (Use spacebar to select `@decentdb/core`, `@decentdb/dotnet`, etc.) + - **What type of bump?** (Major, Minor, or Patch) + - **What is the changelog summary?** (e.g., "Added async queries to .NET wrapper"). +4. This generates a temporary markdown file in the `.changeset/` folder (e.g., `.changeset/smooth-apples-jump.md`). +5. Commit this markdown file along with your code changes. + +*Fixing Oopsies:* If you realize you chose "Minor" instead of "Patch", or you made a typo in the changelog, just open the `.changeset/smooth-apples-jump.md` file in VS Code, manually edit the text, and commit the fix. It's that easy. + +--- + +## 4. Modifying GitHub Actions + +Currently, DecentDB relies on `release.yml` and `nuget.yml` to publish artifacts. We need to split the responsibilities: one Action handles Changeset logic, and the existing Actions handle artifact publishing when tags are created. + +### Action 1: The Changeset Bot (New) +Create `.github/workflows/changesets.yml`: + +```yaml +name: Changesets +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + version-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + + - name: Create Release PR or Publish Tags + id: changesets + uses: changesets/action@v1 + with: + # CRITICAL LINE: + # Stops Changesets from automatically running `npm publish` + # on the `bindings/node` package. It only creates Git tags! + publish: npx changeset tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` +*Note on `publish: npx changeset tag`: Because we are managing a polyglot monorepo, we want consistency. We neuter Changeset's ability to automatically publish the real Node.js package to NPM so that our CI pipelines remain standardized across all languages. The tags will trigger a separate `.github/workflows/node.yml` file to handle the actual publishing.* + +### Action 2: Adapting the .NET Release (`nuget.yml`) +Modify the existing `.github/workflows/nuget.yml` to trigger *only* when the .NET tag is pushed, and inject the version into the build. + +```yaml +name: Publish .NET +on: + push: + tags: + - 'bindings/dotnet@*' # Triggered by Changesets + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract Version + id: extract_version + # Strips 'bindings/dotnet@' from the tag to get '1.3.0' + run: echo "VERSION=${GITHUB_REF#refs/tags/bindings/dotnet@}" >> $GITHUB_ENV + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + # The magic: Pass the extracted version directly into the MSBuild pipeline + - name: Pack + run: dotnet pack bindings/dotnet/DecentDB.csproj -c Release /p:PackageVersion=${{ env.VERSION }} + + - name: Push to NuGet + run: dotnet nuget push **/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json +``` + +### Action 3: Adapting the Core Release (`release.yml`) +Modify `release.yml` to trigger on `src@*`. + +```yaml +name: Release Core +on: + push: + tags: + - 'src@*' + +jobs: + build-and-release: + # Build Nim shared libraries... + # Create GitHub Release attached to the tag... + # Upload assets... +``` + +--- + +## 5. Potential Pitfalls and Considerations + +1. **Native Version Strings**: If your Nim or Python code expects to be able to read its own version at runtime (e.g., `DecentDB.Version() -> "1.2.0"`), dynamically injecting it via CLI arguments during CI might require minor code tweaks. For Nim, you can use `-d:DecentDbVersion="1.2.0"`. For Python, tools like `setuptools_scm` can read the git tag automatically. +2. **The "Empty" Commit**: When you merge the "Version Packages" PR, Changesets creates an empty commit just to attach the tags to the `CHANGELOG.md` updates. +3. **Changelog Formatting**: Changesets will generate a `CHANGELOG.md` inside `src/` and inside each `bindings/*` folder. If you want a unified changelog at the root instead, you will need to write a small custom script to aggregate them, or simply link to the sub-folder changelogs from the main README. +4. **Go Tag Format Nuance**: Changesets creates tags in the format `package-name@version` (e.g., `bindings/go@1.2.3`). However, Go modules strictly require the format `bindings/go/v1.2.3`. You will need to add a post-tag script in the Changeset GitHub Action to detect `bindings/go@X.Y.Z` tags and automatically create and push a `bindings/go/vX.Y.Z` alias tag. + +--- + +## 6. Implementation Guide for Coding Agents + +If an autonomous agent is helping to implement this plan, execute the setup in the following isolated phases: + +### Phase 1: Workspace & State Initialization +1. Create the root `package.json` with the workspace configuration. +2. Traverse the repository and create the dummy `package.json` files for `src` and all `bindings/*` directories (excluding `node`). Ensure the `"name"` strictly matches the directory path (e.g., `"name": "bindings/python"`). +3. Verify the existing `bindings/node/package.json` name is updated to `bindings/node`. +4. Run `npx changeset init` in the root and configure `.changeset/config.json`. +5. Commit this as "chore: setup changesets workspace". + +### Phase 2: GitHub Actions Reconfiguration +1. Create `.github/workflows/changesets.yml` as defined in Step 4. Ensure `publish: npx changeset tag` is strictly set to prevent accidental NPM publishes. +2. **Path Filtering Update**: Update the main PR/Push workflow (e.g., `ci.yml`) to use `paths` filtering. Ensure core tests only run when `src/**` changes, and language tests only run when their respective `bindings/*/**` or `src/**` changes. This prevents CI minute explosion. +3. Update all release workflows (e.g., `release.yml`, `nuget.yml`) to trigger on their new tag prefixes (e.g., `on: push: tags: - 'src@*'`) and dynamically inject the version number from the tag during the build step. +4. Add a specific step in the Changeset workflow to handle Go module tag aliases as described in Step 5 (Pitfall #4). +5. Commit this as "ci: migrate release pipelines to changesets".