diff --git a/Datasync.Toolkit.sln b/Datasync.Toolkit.sln
index f943bc3..ecba367 100644
--- a/Datasync.Toolkit.sln
+++ b/Datasync.Toolkit.sln
@@ -60,6 +60,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.C
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Client.Test", "tests\CommunityToolkit.Datasync.Client.Test\CommunityToolkit.Datasync.Client.Test.csproj", "{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{75F709FD-8CC2-4558-A802-FE57086167C2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Datasync.Server", "samples\datasync-server\src\Sample.Datasync.Server\Sample.Datasync.Server.csproj", "{A9967817-2A2C-4C6D-A133-967A6062E9B3}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -146,6 +150,10 @@ Global
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -171,6 +179,7 @@ Global
{45D47A4E-AD58-40C8-B4CC-95BC888C47A7} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{D3B72031-D4BD-44D3-973C-2752AB1570F6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
+ {A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {78A935E9-8F14-448A-BEDF-360FB742F14E}
diff --git a/infra/scripts/write-runsettings.ps1 b/infra/scripts/write-runsettings.ps1
index a10aa6a..fc07d32 100644
--- a/infra/scripts/write-runsettings.ps1
+++ b/infra/scripts/write-runsettings.ps1
@@ -14,6 +14,7 @@ $fileContents = @"
$($outputs.AZSQL_CONNECTION_STRING)
$($outputs.COSMOS_CONNECTION_STRING)
$($outputs.PGSQL_CONNECTION_STRING)
+ $($outputs.SERVICE_ENDPOINT)
true
diff --git a/samples/datasync-server/src/Sample.Datasync.Server/Db/AppDbContext.cs b/samples/datasync-server/src/Sample.Datasync.Server/Db/AppDbContext.cs
index cc6a61e..af1d07c 100644
--- a/samples/datasync-server/src/Sample.Datasync.Server/Db/AppDbContext.cs
+++ b/samples/datasync-server/src/Sample.Datasync.Server/Db/AppDbContext.cs
@@ -3,16 +3,13 @@
// See the LICENSE file in the project root for more information.
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata;
+using System.Diagnostics.CodeAnalysis;
namespace Sample.Datasync.Server.Db;
-public class AppDbContext : DbContext
+public class AppDbContext(DbContextOptions options) : DbContext(options)
{
- public AppDbContext(DbContextOptions options) : base(options)
- {
-
- }
-
public DbSet TodoItems => Set();
public DbSet TodoLists => Set();
@@ -20,5 +17,40 @@ public AppDbContext(DbContextOptions options) : base(options)
public async Task InitializeDatabaseAsync()
{
_ = await Database.EnsureCreatedAsync();
+
+ const string datasyncTrigger = @"
+ CREATE OR ALTER TRIGGER [dbo].[{0}_datasync] ON [dbo].[{0}] AFTER INSERT, UPDATE AS
+ BEGIN
+ SET NOCOUNT ON;
+ UPDATE
+ [dbo].[{0}]
+ SET
+ [UpdatedAt] = SYSUTCDATETIME()
+ WHERE
+ [Id] IN (SELECT [Id] FROM INSERTED);
+ END
+ "
+ ;
+
+ // Install the above trigger to set the UpdatedAt field automatically on insert or update.
+ foreach (IEntityType table in Model.GetEntityTypes())
+ {
+ string sql = string.Format(datasyncTrigger, table.GetTableName());
+ _ = await Database.ExecuteSqlRawAsync(sql);
+ }
+ }
+
+ [SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "Model builder ignores return value.")]
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ // Tells EF Core that the TodoItem entity has a trigger.
+ modelBuilder.Entity()
+ .ToTable(tb => tb.HasTrigger("TodoItem_datasync"));
+
+ // Tells EF Core that the TodoList entity has a trigger.
+ modelBuilder.Entity()
+ .ToTable(tb => tb.HasTrigger("TodoList_datasync"));
+
+ base.OnModelCreating(modelBuilder);
}
}
diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs
index 1105607..911174c 100644
--- a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs
+++ b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs
@@ -110,7 +110,7 @@ internal Dictionary GetEntityMap(OfflineDbContext context)
///
/// Retrieves the existing operation that matches an operation for the provided entity.
///
- /// The entity being processed.
+ /// The entity entry being processed.
/// A to observe.
/// The operation entity or null if one does not exist.
/// Thrown if the entity ID of the provided entity is invalid.
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Live/SampleServerTests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Live/SampleServerTests.cs
new file mode 100644
index 0000000..a0ac1b2
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Live/SampleServerTests.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Net.Http.Json;
+
+namespace CommunityToolkit.Datasync.Client.Test.Live;
+
+[ExcludeFromCodeCoverage]
+public class SampleServerTests
+{
+ private readonly bool liveTestsAreEnabled = Environment.GetEnvironmentVariable("DATASYNC_SERVICE_ENDPOINT") is not null;
+ private readonly string serviceEndpoint = Environment.GetEnvironmentVariable("DATASYNC_SERVICE_ENDPOINT");
+
+ [SkippableFact]
+ public async Task Metadata_GetsSetByServer()
+ {
+ Skip.IfNot(this.liveTestsAreEnabled);
+
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ HttpClient client = new();
+ TodoItem source = new() { Title = "Test item" };
+ HttpResponseMessage response = await client.PostAsJsonAsync($"{this.serviceEndpoint}/tables/TodoItem", source);
+
+ response.Should().HaveHttpStatusCode(HttpStatusCode.Created);
+
+ TodoItem result = await response.Content.ReadFromJsonAsync();
+ result.Id.Should().NotBeNullOrEmpty();
+ result.UpdatedAt.Should().NotBeNull().And.BeAfter(now);
+ result.Version.Should().NotBeNullOrEmpty();
+ result.Deleted.Should().BeFalse();
+ result.Title.Should().Be("Test item");
+ result.IsComplete.Should().BeFalse();
+
+ response.Headers.Location.Should().NotBeNull().And.BeEquivalentTo(new Uri($"{this.serviceEndpoint}/tables/TodoItem/{result.Id}"));
+ response.Headers.ETag.ToString().Should().Be($"\"{result.Version}\"");
+ }
+
+ // This must match the TodoItem class in the server project.
+ public class TodoItem
+ {
+ public string Id { get; set; }
+ public DateTimeOffset? UpdatedAt { get; set; }
+ public string Version { get; set; }
+ public bool Deleted { get; set; }
+ public string Title { get; set; }
+ public bool IsComplete { get; set; }
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs
index 49d7ac9..6fced11 100644
--- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs
+++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs
@@ -37,7 +37,7 @@ internal void InitializeDatabase(bool clearEntities)
UPDATE
[dbo].[{0}]
SET
- [UpdatedAt] = GETUTCDATE()
+ [UpdatedAt] = SYSUTCDATETIME()
WHERE
[Id] IN (SELECT [Id] FROM INSERTED);
END