CrdtSync 1.2.1

dotnet add package CrdtSync --version 1.2.1
                    
NuGet\Install-Package CrdtSync -Version 1.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="CrdtSync" Version="1.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="CrdtSync" Version="1.2.1" />
                    
Directory.Packages.props
<PackageReference Include="CrdtSync" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add CrdtSync --version 1.2.1
                    
#r "nuget: CrdtSync, 1.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package CrdtSync@1.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=CrdtSync&version=1.2.1
                    
Install as a Cake Addin
#tool nuget:?package=CrdtSync&version=1.2.1
                    
Install as a Cake Tool

CrdtSync

A lightweight State-based LWW-Map CRDT (Last-Writer-Wins Map) sync engine for .NET with batteries included. Mark properties with [Synced], extend SyncableEntity, and get automatic per-field merge with last-writer-wins conflict resolution — plus coalesced push queues, retry helpers, and transport abstractions.

Features

  • Per-field conflict resolution — each property tracks its own timestamp, so concurrent edits to different fields on the same entity never conflict
  • Last-Writer-Wins — when two devices edit the same field, the most recent write wins automatically
  • Hybrid Logical Clock — tolerates clock skew between machines (up to seconds), ensuring correct merge even when system clocks differ
  • Coalesced push queue — batches rapid edits into a single sync operation with configurable delay
  • Retry-on-conflict helper — download → merge → upload loop with automatic retry on version conflicts
  • Transport abstractionISyncTransport<T> interface for pluggable backends (Google Drive, local files, S3, etc.)
  • Generic sync file envelopeSyncFile<T> with version counter and schema versioning
  • Compare-and-swap safe — designed for transports without native optimistic concurrency (e.g. Google Drive)
  • Zero configuration — just inherit SyncableEntity, add [Synced] to properties, and implement SyncKey
  • Reflection-cached — property metadata is built once per type and reused across all instances
  • INotifyPropertyChanged — built-in change notification for MVVM/UI binding
  • Logging — optional Microsoft.Extensions.Logging integration; silent by default

Installation

dotnet add package CrdtSync

Quick Start

1. Define your entity

using CrdtSync;

public class TodoItem : SyncableEntity
{
    private string _title = "";
    private bool _isDone;
    private int _priority;

    // SyncKey matches entities across devices (must be unique)
    public override string SyncKey => _title;

    [Synced]
    public string Title
    {
        get => _title;
        set => SetField(ref _title, value);
    }

    [Synced]
    public bool IsDone
    {
        get => _isDone;
        set => SetField(ref _isDone, value);
    }

    [Synced(ClearValue = 0)]
    public int Priority
    {
        get => _priority;
        set => SetField(ref _priority, value);
    }
}

2. Edit entities (timestamps are tracked automatically)

var item = new TodoItem { SuppressTimestamp = false };
item.Title = "Buy milk";
item.Priority = 3;
// FieldTimestamps now contains HLC timestamps for "Title" and "Priority"

3. Merge from a remote copy

// Single entity merge
bool changed = localItem.MergeFrom(remoteItem);

// Bulk merge — matches by SyncKey, returns changed count + new entities
var (mergeCount, newItems) = SyncableEntity.MergeAll(localList, remoteList, "Device1");
localList.AddRange(newItems);

4. Serialize and transport however you like

using System.Text.Json;

// Serialize
var json = JsonSerializer.Serialize(localList);

// Deserialize (SuppressTimestamp defaults to true, so deserialization won't overwrite timestamps)
var remoteList = JsonSerializer.Deserialize<List<TodoItem>>(json);

Coalesced Push Queue

When users make rapid edits (typing, toggling checkboxes), you don't want to trigger a sync for each keystroke. CoalescedPushQueue batches these into a single operation after a configurable delay:

var pushQueue = new CoalescedPushQueue(delayMs: 200);

// In your property-changed handler:
pushQueue.Enqueue("todos", async () => await SyncToRemoteAsync());

// Before app shutdown — flush any pending edits:
await pushQueue.FlushAsync();

// Listen for flush completions:
pushQueue.FlushCompleted += allOk =>
{
    if (!allOk) Log.Warning("Some push actions failed");
};

Each Enqueue call resets the timer. Actions are only flushed when the timer fires (no new edits within the delay window). All actions for the same file key are executed sequentially in one batch.

Sync Retry Helper

SyncRetryHelper<T> implements the standard download → merge → upload retry loop for CRDT sync. When a version conflict is detected (another device uploaded concurrently), it re-downloads, re-merges, and retries:

// Implement ISyncTransport<T> for your backend
public class MyDriveTransport : ISyncTransport<TodoItem>
{
    public long LastKnownVersion { get; private set; }

    public async Task<(List<TodoItem> Records, bool VersionOk)> DownloadAsync()
    {
        // Download from remote, check version against LastKnownVersion
    }

    public async Task UploadAsync(List<TodoItem> records)
    {
        // Upload to remote, increment version
    }
}

// Use the retry helper
var transport = new MyDriveTransport();
var helper = new SyncRetryHelper<TodoItem>(transport);

var result = await helper.MergeAndUploadAsync(
    localEntities: myLocalItems,
    deviceName: Environment.MachineName,
    beforeMerge: () => RefreshFromDatabaseAsync() // Optional: re-stamp before each attempt
);

Console.WriteLine($"Merged {result.MergeCount} entities, {result.NewEntities.Count} new, {result.Attempts} attempts");

Sync File Envelope

SyncFile<T> is a generic JSON envelope for synced entity collections with version tracking:

var syncFile = new SyncFile<TodoItem>
{
    SchemaVersion = 3,
    Version = 42,
    LastModified = DateTime.UtcNow,
    Records = myItems
};

var json = JsonSerializer.Serialize(syncFile);

Use the version counter for compare-and-swap: if the remote version differs from your last-known version, another device uploaded concurrently and you need to re-merge before uploading.

Transport Interface

ISyncTransport<T> provides a minimal abstraction for sync backends:

public interface ISyncTransport<T> where T : SyncableEntity
{
    Task<(List<T> Records, bool VersionOk)> DownloadAsync();
    Task UploadAsync(List<T> records);
    long LastKnownVersion { get; }
}

Implementations handle version tracking and compare-and-swap internally. The SyncRetryHelper<T> builds the retry loop on top.

Hybrid Logical Clock (HLC)

CrdtSync uses a Hybrid Logical Clock to generate timestamps instead of raw DateTime.UtcNow. This solves a common problem with multi-device sync: clock skew.

When two machines have slightly different system clocks (even a few seconds), a plain DateTime.UtcNow comparison can give the wrong merge result — an edit made later in real time can lose to an earlier edit from a machine with a faster clock.

The HLC guarantees:

  • Timestamps are monotonically increasing — every new timestamp is strictly greater than the previous one
  • When merging remote data, the clock witnesses remote timestamps and advances past them
  • A subsequent local edit always gets a timestamp greater than any previously seen value

This means that as long as devices sync periodically, the HLC self-corrects for clock drift automatically. No NTP configuration required.

// The HLC is used automatically by SetField.
// For testing with simulated clock skew:
HybridClock.SetPhysicalClock(() => DateTime.UtcNow.AddSeconds(-5));
HybridClock.Reset(); // Reset state (for test isolation)

API Reference

SyncableEntity (abstract base class)

Member Description
abstract string SyncKey Unique key to match entities across devices (e.g. Name, Id)
Dictionary<string, DateTime> FieldTimestamps Per-field timestamps for merge decisions
bool SuppressTimestamp When true, property changes don't update timestamps. Defaults to true — set to false after construction/deserialization
bool MergeFrom(SyncableEntity other) Merge per-field from another entity. Returns true if any value changed
void Clear() Reset all [Synced] properties to their ClearValue defaults
static (int, List<T>) MergeAll<T>(...) Bulk merge remote list into local list by SyncKey
static Action<T, Random>[] BuildRandomEditActions<T>() Generate random edit actions per [Synced] property (useful for testing)
static IReadOnlyList<SyncedPropertyMeta> GetSyncedProperties(Type) Get cached reflection metadata for a type
protected bool SetField<T>(ref T, T, string?) Set a backing field, auto-update timestamp via HLC, and raise PropertyChanged

CoalescedPushQueue

Member Description
CoalescedPushQueue(int delayMs = 200, ILogger? logger = null) Create queue with configurable delay
void Enqueue(string fileKey, Func<Task> action) Queue an action. Resets the coalesce timer
Task FlushAsync() Immediately drain and execute all queued actions
bool HasPending True if there are actions waiting to be flushed
event Action<bool>? FlushCompleted Raised after each flush (true = all actions succeeded)

SyncRetryHelper<T>

Member Description
SyncRetryHelper(ISyncTransport<T>, ILogger?) Create helper with a transport backend
int MaxRetries { get; set; } Maximum retry attempts on version conflict (default: 3)
Task<MergeResult<T>> MergeAndUploadAsync(...) Download → merge → upload with retry loop

MergeResult<T>

Member Description
int MergeCount Number of existing entities updated by remote data
List<T> NewEntities Entities from remote not present locally
int Attempts Number of attempts taken (1 = no retries)

SyncFile<T>

Member Description
int SchemaVersion Envelope format version (default: 3)
long Version Linear version counter for compare-and-swap
DateTime LastModified UTC timestamp of last upload
List<T> Records The synced entity collection

ISyncTransport<T>

Member Description
Task<(List<T>, bool)> DownloadAsync() Download records + version check
Task UploadAsync(List<T> records) Upload merged records
long LastKnownVersion Last-known remote version

HybridClock (static)

Member Description
static DateTime Now() Returns a monotonically increasing timestamp (max of physical clock and last known + 1ms)
static void Witness(DateTime remoteTime) Advances the clock past a remote timestamp. Called automatically during MergeFrom
static void Reset() Resets clock state (for testing only)
static void SetPhysicalClock(Func<DateTime>?) Overrides the physical clock source (for testing clock skew)

[Synced] attribute

Property Type Default Description
ClearValue object? null Value used by Clear(). If null, uses default(T) (empty string for string)
DbType string "" Optional SQLite column type hint for DB migration scenarios

Logging

CrdtSync uses Microsoft.Extensions.Logging. By default logging is disabled (NullLogger). To enable:

// Option A: Pass an ILogger directly
SyncableEntity.SetLogger(myLogger);

// Option B: Pass an ILoggerFactory
SyncableEntity.SetLogger(loggerFactory);

// With Serilog (add Serilog.Extensions.Logging package):
using Serilog.Extensions.Logging;
SyncableEntity.SetLogger(new SerilogLoggerFactory(Log.Logger).CreateLogger("CrdtSync"));

How It Works

CrdtSync implements a State-based LWW-Map CRDT with Hybrid Logical Clock:

  1. Each entity has a FieldTimestamps dictionary mapping property names to their last-edit time
  2. When SetField is called (and SuppressTimestamp is false), the field's timestamp is set via HybridClock.Now() — which returns max(physicalClock, lastKnown + 1ms)
  3. During MergeFrom, each [Synced] property is compared by timestamp — the newer value wins. Remote timestamps are witnessed by the HLC so future local edits always get a greater timestamp
  4. MergeAll matches entities across lists using SyncKey, merges existing ones, and returns entities that only exist remotely
  5. CoalescedPushQueue batches rapid edits so your transport isn't overwhelmed
  6. SyncRetryHelper handles version conflicts automatically with download → re-merge → upload retry

This means:

  • No central server needed — any device can merge with any other device
  • Offline-friendly — edits accumulate timestamps locally and resolve on next sync
  • No data loss — only individual fields are overwritten, not entire objects
  • Clock-skew tolerant — the HLC ensures correct ordering even when system clocks differ by seconds

Supported Property Types

Any type that works with EqualityComparer<T>.Default and can be set via reflection:

  • string (default clear value: "")
  • int, bool, double, float, decimal
  • DateTime, Guid
  • Enums
  • Any serializable reference type

Requirements

  • .NET 8.0 or later
  • Microsoft.Extensions.Logging.Abstractions (included automatically)

License

MIT

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.1 88 3/23/2026
1.2.0 82 3/19/2026
1.1.0 84 3/10/2026
1.0.2 96 3/10/2026
1.0.1 84 3/9/2026
1.0.0 81 3/9/2026