Sync Framework
A database-agnostic, offline-first synchronization framework for .NET applications. Enables two-way data synchronization between distributed replicas with conflict resolution, tombstone management, and real-time subscriptions.
Overview
The Sync framework provides:
- Offline-first architecture - Work locally, sync when connected
- Two-way synchronization - Pull changes from server, push local changes
- Conflict resolution - Last-write-wins, server-wins, client-wins, or custom strategies
- Foreign key handling - Automatic deferred retry for FK violations during sync
- Tombstone management - Safe deletion tracking for late-syncing clients
- Real-time subscriptions - Subscribe to changes on specific records or tables
- Hash verification - SHA-256 integrity checking for batches and databases
- Database agnostic - Currently supports SQLite and PostgreSQL
Projects
| Project | Description |
|---|---|
Sync |
Core synchronization engine (platform-agnostic) |
Sync.SQLite |
SQLite-specific implementation |
Sync.Postgres |
PostgreSQL-specific implementation |
Sync.Api |
REST API server with SSE real-time subscriptions |
Sync.Tests |
Core engine tests |
Sync.SQLite.Tests |
SQLite integration tests |
Sync.Postgres.Tests |
PostgreSQL integration tests |
Sync.Api.Tests |
API endpoint tests |
Sync.Integration.Tests |
Cross-database E2E tests |
Getting Started
Prerequisites
- .NET 9.0 SDK
- For PostgreSQL: Docker (or a local PostgreSQL instance)
Installation
Add the appropriate NuGet packages to your project:
<!-- Core sync engine -->
<PackageReference Include="Sync" Version="1.0.0" />
<!-- Choose your database provider -->
<PackageReference Include="Sync.SQLite" Version="1.0.0" />
<!-- or -->
<PackageReference Include="Sync.Postgres" Version="1.0.0" />
Basic Setup (SQLite)
1. Initialize the Sync Schema
using Microsoft.Data.Sqlite;
using Sync.SQLite;
// Create your database connection
using var connection = new SqliteConnection("Data Source=myapp.db");
connection.Open();
// Create sync tables (_sync_log, _sync_state, _sync_session, etc.)
SyncSchema.CreateSchema(connection);
SyncSchema.InitializeSyncState(connection, originId: Guid.NewGuid().ToString());
2. Add Triggers to Your Tables
// Generate and apply sync triggers for a table
var triggerResult = TriggerGenerator.GenerateTriggers(connection, "Person");
if (triggerResult is TriggerListOk ok)
{
foreach (var trigger in ok.Value)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = trigger;
cmd.ExecuteNonQuery();
}
}
This creates INSERT, UPDATE, and DELETE triggers that automatically log changes to _sync_log.
3. Perform Synchronization
using Sync;
// Create delegate functions for database operations
Func<long, int, SyncBatchResult> fetchRemoteChanges = (fromVersion, batchSize) =>
SyncLogRepository.FetchChanges(remoteConnection, fromVersion, batchSize);
Func<SyncLogEntry, BoolSyncResult> applyChange = (entry) =>
ChangeApplierSQLite.ApplyChange(localConnection, entry);
Func<BoolSyncResult> enableSuppression = () =>
SyncSessionManager.EnableSuppression(localConnection);
Func<BoolSyncResult> disableSuppression = () =>
SyncSessionManager.DisableSuppression(localConnection);
// Pull changes from remote
var pullResult = SyncCoordinator.Pull(
fetchRemoteChanges,
applyChange,
enableSuppression,
disableSuppression,
getLastServerVersion: () => SyncLogRepository.GetLastServerVersion(localConnection),
updateLastServerVersion: (v) => SyncLogRepository.UpdateLastServerVersion(localConnection, v),
localOriginId: myOriginId,
config: new BatchConfig(BatchSize: 1000, MaxRetryPasses: 3),
logger: NullLogger.Instance
);
// Push local changes to remote
var pushResult = SyncCoordinator.Push(
fetchLocalChanges: (fromVersion, batchSize) =>
SyncLogRepository.FetchChanges(localConnection, fromVersion, batchSize),
sendToRemote: (batch) => ApplyBatchToRemote(remoteConnection, batch),
getLastPushVersion: () => SyncLogRepository.GetLastPushVersion(localConnection),
updateLastPushVersion: (v) => SyncLogRepository.UpdateLastPushVersion(localConnection, v),
config: new BatchConfig(),
logger: NullLogger.Instance
);
Using the REST API
Start the API Server
cd Sync/Sync.Api
dotnet run
API Endpoints
| Endpoint | Method | Description |
|---|---|---|
/health |
GET | Health check |
/sync/changes |
GET | Pull changes from server |
/sync/changes |
POST | Push changes to server |
/sync/clients |
POST | Register a sync client |
/sync/state |
GET | Get server sync state |
/sync/subscribe |
GET | Subscribe to real-time changes (SSE) |
/sync/subscribe/{id} |
DELETE | Unsubscribe |
Pull Changes
curl "http://localhost:5000/sync/changes?fromVersion=0&batchSize=100&connectionString=Data%20Source=server.db&dbType=sqlite"
Response:
{
"changes": [
{
"version": 1,
"tableName": "Person",
"pkValue": "{\"Id\":1}",
"operation": "Insert",
"payload": "{\"Id\":1,\"Name\":\"Alice\",\"Email\":\"alice@example.com\"}",
"origin": "client-abc",
"timestamp": "2025-01-15T10:30:00.000Z"
}
],
"fromVersion": 0,
"toVersion": 1,
"hasMore": false
}
Push Changes
curl -X POST "http://localhost:5000/sync/changes?connectionString=Data%20Source=server.db&dbType=sqlite" \
-H "Content-Type: application/json" \
-d '{
"OriginId": "client-xyz",
"Changes": [
{
"version": 0,
"tableName": "Person",
"pkValue": "{\"Id\":2}",
"operation": "Insert",
"payload": "{\"Id\":2,\"Name\":\"Bob\"}",
"origin": "client-xyz",
"timestamp": "2025-01-15T11:00:00.000Z"
}
]
}'
Real-Time Subscriptions (SSE)
# Subscribe to all changes on the Person table
curl "http://localhost:5000/sync/subscribe?tableName=Person"
# Subscribe to a specific record
curl "http://localhost:5000/sync/subscribe?tableName=Person&pkValue=1"
PostgreSQL Setup
1. Start PostgreSQL with Docker
From the repository root:
docker-compose up -d
This starts a single PostgreSQL container on localhost:5432 (user: postgres, password: postgres, database: gigs). The C# migrations handle schema creation.
2. Initialize Schema
using Npgsql;
using Sync.Postgres;
using var connection = new NpgsqlConnection(
"Host=localhost;Port=5432;Database=gigs;Username=postgres;Password=postgres");
connection.Open();
PostgresSyncSchema.CreateSchema(connection);
PostgresSyncSchema.InitializeSyncState(connection, originId: Guid.NewGuid().ToString());
Architecture
Sync Tables
The framework creates these tables in your database:
| Table | Purpose |
|---|---|
_sync_log |
Change log with version, table, PK, operation, payload, origin, timestamp |
_sync_state |
Local replica state (origin_id, last_server_version, last_push_version) |
_sync_session |
Trigger suppression flag (sync_active) |
_sync_clients |
Server-side client tracking for tombstone management |
_sync_subscriptions |
Real-time subscription registrations |
Change Capture
When you modify a tracked table:
- AFTER trigger fires (if
sync_active = 0) - Trigger inserts row into
_sync_logwith:- Auto-incrementing version
- Table name and primary key (JSON)
- Operation (Insert/Update/Delete)
- Full row payload (JSON) for Insert/Update, NULL for Delete
- Origin ID (prevents echo during sync)
- UTC timestamp
Sync Flow
Pull (receive changes):
- Enable trigger suppression (
sync_active = 1) - Fetch batch from remote (version > lastServerVersion)
- Apply changes with FK violation defer/retry
- Skip changes from own origin (echo prevention)
- Update lastServerVersion
- Repeat until no more changes
- Disable trigger suppression
Push (send changes):
- Fetch local changes (version > lastPushVersion)
- Send batch to remote
- Update lastPushVersion
- Repeat until no more changes
Conflict Resolution
When the same row is modified by different origins:
// Default: Last-write-wins (by timestamp, version as tiebreaker)
var resolved = ConflictResolver.Resolve(
localEntry,
remoteEntry,
ConflictStrategy.LastWriteWins
);
// Or use custom resolution
var resolved = ConflictResolver.ResolveCustom(
localEntry,
remoteEntry,
(local, remote) => /* your merge logic */
);
Hash Verification
Verify data integrity with SHA-256 hashes:
// Hash a batch of changes
var batchHash = HashVerifier.ComputeBatchHash(changes);
// Hash entire database state
var dbHash = HashVerifier.ComputeDatabaseHash(
fetchAllChanges: () => SyncLogRepository.FetchAll(connection)
);
// Verify batch integrity
var isValid = HashVerifier.VerifyHash(expectedHash, actualHash);
Running Tests
# All tests
dotnet test
# Specific test projects
dotnet test --filter "FullyQualifiedName~Sync.Tests"
dotnet test --filter "FullyQualifiedName~Sync.SQLite.Tests"
dotnet test --filter "FullyQualifiedName~Sync.Postgres.Tests"
dotnet test --filter "FullyQualifiedName~Sync.Api.Tests"
# Cross-database integration tests (requires Docker)
dotnet test --filter "FullyQualifiedName~Sync.Integration.Tests"
Configuration
BatchConfig
var config = new BatchConfig(
BatchSize: 1000, // Changes per batch (default: 1000)
MaxRetryPasses: 3 // FK violation retry attempts (default: 3)
);
Tombstone Management
// Calculate safe version to purge (all clients have synced past this)
var safeVersion = TombstoneManager.CalculateSafePurgeVersion(
getAllClients: () => SyncClientRepository.GetAll(connection)
);
// Purge old tombstones
TombstoneManager.PurgeTombstones(
purge: (version) => SyncLogRepository.PurgeBefore(connection, version),
safeVersion
);
// Detect stale clients (90 days inactive by default)
var staleClients = TombstoneManager.FindStaleClients(
getAllClients: () => SyncClientRepository.GetAll(connection),
inactivityThreshold: TimeSpan.FromDays(90)
);
Error Handling
All operations return Result<TValue, SyncError>:
var result = SyncCoordinator.Pull(...);
if (result is PullResultOk ok)
{
Console.WriteLine($"Pulled {ok.Value.ChangesApplied} changes");
}
else if (result is PullResultError error)
{
switch (error.Value)
{
case SyncErrorForeignKeyViolation fk:
Console.WriteLine($"FK violation: {fk.Message}");
break;
case SyncErrorFullResyncRequired:
Console.WriteLine("Client fell too far behind, full resync needed");
break;
case SyncErrorHashMismatch hash:
Console.WriteLine($"Data integrity error: {hash.Expected} != {hash.Actual}");
break;
// ... handle other error types
}
}
Design Principles
This framework follows the coding rules from CLAUDE.md:
- No exceptions - All fallible operations return
Result<T, SyncError> - No classes - Uses records and static methods (FP style)
- No interfaces - Uses
Func<T>andAction<T>for abstractions - Integration testing - No mocks, tests use real databases
- Copious logging - All operations log via
ILogger
License
See repository root for license information.