Skip to content
Document DB v7: Temporal Support Feed The Machine Here

Getting Started

GitHubGitHub stars for shinyorg/shiny
DownloadsNuGet downloads for Shiny.Data.Sync
Frameworks
.NET
.NET MAUI
Blazor
Operating Systems
Android
iOS
macOS
Windows
Linux

Shiny.Data.Sync is a reliable, background-capable, bidirectional JSON record-sync engine between a mobile / desktop / web app and an HTTP backend. Where Shiny.Net.Http moves files in the background, Shiny.Data.Sync moves records: Create / Update / Delete operations queued in a local outbox and drained against a REST API, with a complementary inbox pulling deltas back from the server.

If you’ve been waiting for “EF Sync, but for mobile that actually survives suspension” — this is that.

  • An outboxQueue<T>(verb, entity) writes a SyncOperation to a persistent repository. The transport drains it as soon as connectivity is available, retries transient failures with exponential backoff, and persists the next-attempt time so a process restart resumes the wait window mid-air.
  • An inboxPullNow<T>() and PullAll() fetch deltas keyed by an opaque cursor. A single call drains the full delta set (the engine re-pulls while hasMore: true), then optionally fetches tombstones, all on the same cursor stream.
  • A scheduled jobAddDataSync auto-registers a SyncJob with the Shiny Jobs scheduler so periodic pulls keep running while the app is backgrounded. No AddJob call required.
  • A connectivity loop — every transport listens on IConnectivity.Changed. A network transition offline → online immediately drains the outbox and runs PullAll.
  • One activity stream — every lifecycle moment (queued, started, sent, failed, conflict, retry-scheduled, canceled, pull-started, item-received, pull-completed, pull-failed, tombstones-applied) surfaces through a single Activity event.
PlatformOutbox transportInbox transportSurvives app kill
iOS / Mac CatalystBackground NSURLSession upload taskBackground NSURLSession download taskYes (both directions)
AndroidForeground Service + HttpClientHttpClient (in-process)Outbox yes (foreground notification visible); inbox no
Windows / Linux / macOS / base .NETHttpClient + connectivity loopHttpClient + connectivity loopNo — resumes on next launch
Blazor WebAssemblyHttpClient + LocalStorageHttpClient + LocalStorageNo — sync runs while the tab is open

The right transport is picked automatically by AddDataSync<TDelegate> based on the target TFM. See Architecture for why each tier looks the way it does, and Platform Behavior for the details and required Info.plist / manifest bits.

Shiny.Data.SyncNuGet package Shiny.Data.Sync
Shiny.Hosting.MauiNuGet package Shiny.Hosting.Maui

Anything sync’d through the engine must implement ISyncEntity — a single property:

using Shiny.Data.Sync;
public record TodoItem(string Identifier, string Title, bool Completed) : ISyncEntity;

That’s it. No base classes, no attributes, no special interfaces beyond ISyncEntity. The Identifier is the stable, server-recognized id — it goes into the URL for PUT /{id} / DELETE /{id}, is used to dedupe within a batch, and identifies the entity in tombstone deletes.

All serialization runs through Shiny.Json.Default — the shared ISerializer from Shiny.Extensions.Serialization. Tag your entity in a [ShinyJsonContext]-attributed context and a source-generated module initializer wires the type into the shared chain before any code runs:

[ShinyJsonContext]
[JsonSerializable(typeof(TodoItem))]
public partial class AppJsonContext : JsonSerializerContext;

You only need one such context per app — every endpoint that registers a type reachable from it is covered automatically. Trimming, NativeAOT, and source-only iOS-AOT builds all just work. To customize global serializer options use services.ConfigureJsonSerializer(opts => ...) from Shiny.Extensions.Serialization.

Your app supplies one IDataSyncDelegate. It is the integration seam between the engine and your local store.

using Shiny.Data.Sync;
public class MyDataSyncDelegate(IMyLocalStore store) : IDataSyncDelegate
{
public Task OnSent(SyncOperation op, string? responseBody)
{
// Server accepted the op. responseBody may carry a server-assigned id, ETag, etc.
return Task.CompletedTask;
}
public Task OnError(SyncOperation op, int statusCode, Exception ex)
{
// The op exhausted its retry budget. Persist to a dead-letter store, surface to the user,
// or re-queue with a different shape. The original op is already removed from the outbox.
return Task.CompletedTask;
}
public Task OnReceived(SyncReceivedItem item)
{
// item.Entity is already deserialized via Shiny.Json.Default — strongly-typed.
// item.RawPayload is also available if you need to deserialize against a different schema.
if (item.Entity is TodoItem todo)
store.Apply(todo, item.Verb);
return Task.CompletedTask;
}
public Task<ConflictResolution> OnConflict(SyncOperation op, string remotePayload)
=> Task.FromResult(ConflictResolution.AcceptRemote);
}

The four methods are the only opinionated contract the library imposes. See Conflict Resolution for the merge story.

AddDataSync<TDelegate> picks the right transport for the target TFM, registers the delegate, wires the default REST transport behind an IHttpClientFactory-named client, and auto-registers the periodic SyncJob. Endpoint registration runs inside the callback:

using Shiny;
using Shiny.Data.Sync;
using Shiny.Data.Sync.Infrastructure;
builder.Services.AddDataSync<MyDataSyncDelegate>(opts =>
{
// The simple case — defaults are sane.
opts.RegisterEndpoint<TodoItem>("https://api.example.com/todos");
// The configured case.
opts.RegisterEndpoint<Project>("https://api.example.com/projects", ep =>
{
ep.Direction = SyncDirection.Both; // or PullOnly / PushOnly
ep.Batch = true; // coalesce queued ops per round-trip
ep.UseMeteredConnection = false; // wait for WiFi
ep.MinPullInterval = TimeSpan.FromMinutes(5); // throttle scheduled pulls
ep.MaxAttempts = 8; // retry transient failures up to 8x
ep.RetryBaseDelay = TimeSpan.FromSeconds(3);
ep.DefaultConflictPolicy = ConflictPolicy.ServerWins;
ep.TombstoneUrl = "https://api.example.com/projects/deleted";
ep.SoftDeletePredicate = entity => entity is Project p && p.IsArchived;
});
});
// Optional: a single cross-cutting auth header for every sync request
builder.Services.AddSyncInterceptor<MyAuthInterceptor>();
// Centralize base address / Polly handlers / message handlers on the named client.
// RestSyncTransport.HttpClientName is the constant "Shiny.Data.Sync".
builder.Services
.AddHttpClient(RestSyncTransport.HttpClientName, c => c.BaseAddress = new Uri("https://api.example.com"))
.AddPolicyHandler(GetRetryPolicy());

For Blazor WASM use AddBlazorDataSync<TDelegate> from Shiny.Data.Sync.Blazor — it persists the outbox and cursors to LocalStorage and drives sync from an in-process HttpClient loop on connectivity events:

builder.Services.AddBlazorDataSync<MyDataSyncDelegate>(opts =>
{
opts.RegisterEndpoint<TodoItem>("/api/todos");
});
builder.Services.AddHttpClient(RestSyncTransport.HttpClientName, c =>
c.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress));

Sync only runs while the tab is open — there is no Service Worker Background Sync hook today.

Forcing the HttpClient path on Apple / Android

Section titled “Forcing the HttpClient path on Apple / Android”

If you need the cross-platform HttpClient loop on a native target (for testing, to skip NSURLSession constraints, or because your interceptor needs the request body), register explicitly:

builder.Services.AddHttpClientDataSync<MyDataSyncDelegate>(opts =>
{
opts.RegisterEndpoint<TodoItem>("https://api.example.com/todos");
});

You give up the survives-app-kill guarantee on iOS / Mac Catalyst, and on Android you lose the foreground-service notification (the queue runs in-process and pauses when the OS reclaims the process).

public class TodosService(IDataSyncManager sync)
{
public Task Create(TodoItem item) => sync.Queue(SyncVerb.Create, item);
public Task Update(TodoItem item) => sync.Queue(SyncVerb.Update, item);
public Task Delete(TodoItem item) => sync.Queue(SyncVerb.Delete, item);
// Pull-to-refresh on a single endpoint. Bypasses MinPullInterval.
public Task Refresh(CancellationToken ct = default) => sync.PullNow<TodoItem>(ct);
// Pull every Both/PullOnly endpoint. Respects MinPullInterval per endpoint.
// SyncJob calls this on the background scheduler — you rarely need to call it manually.
public Task RefreshAll(CancellationToken ct = default) => sync.PullAll(ct);
}

Queue<T> writes the operation to the persistent repository immediately and returns. On Apple it kicks off an NSURLSession upload task; on Android it starts the foreground service if it isn’t already running; elsewhere it kicks the HttpClient loop. The caller doesn’t wait for the network round-trip — that happens in the background and the result surfaces through events / the delegate.

sync.PendingCountChanged += (s, count) => StatusLabel.Text = $"{count} pending";
sync.UpdateReceived += (s, result) =>
{
if (result.State == SyncOperationState.Error)
ShowToast($"Sync failed: {result.Exception?.Message}");
};
sync.PullCompleted += (s, c) =>
{
if (c.Error != null) ShowToast($"Pull failed for {c.EndpointKey}: {c.Error.Message}");
else if (c.ItemsReceived > 0) ShowToast($"{c.ItemsReceived} new {c.EndpointKey} items");
};
// Unified stream — covers every outbox + inbox lifecycle moment
sync.Activity += (s, evt) =>
Console.WriteLine($"{evt.Type} {evt.EndpointKey} items={evt.ItemCount} status={evt.StatusCode}");

Activity fires SyncEvent records for OutboxQueued, OutboxStarted, OutboxSent, OutboxFailed, OutboxConflict, OutboxRetryScheduled, OutboxCanceled, InboxPullStarted, InboxItemReceived, InboxPullCompleted, InboxPullFailed, and TombstonesApplied. It’s the right hook for telemetry, status bars, and toast notifications. The typed events (PendingCountChanged / UpdateReceived / PullCompleted) are kept for fine-grained subscribers.

await sync.Cancel(operationId); // one operation
await sync.CancelAll<TodoItem>(); // every queued op for one endpoint
await sync.CancelAll(); // entire outbox (in-flight inbox pulls are left alone)

On Apple these translate to NSURLSessionTask.Cancel() on the upload task with the matching TaskDescription, so an in-flight background upload also stops.

┌─────────────────────────────────────────────────────────────┐
│ Your code │
│ ───────── │
│ Queue<T>(verb, entity) ──┐ ▲ │
│ PullNow<T>() ────────────┼───────────► │ delegate │
│ │ │ OnReceived │
│ ▼ │ OnSent │
│ IDataSyncManager │ OnError │
│ │ │ OnConflict │
│ ▼ │ │
│ Outbox (persisted) Inbox dispatch │
│ │ ▲ │
│ ▼ │ │
│ ┌──────────────────────────────┴──┐ │
│ │ ISyncTransport │ │
│ │ (default: RestSyncTransport) │ │
│ └─────────────┬───────────────────┘ │
│ │ │
└───────────────────────────┼─────────────────────────────────┘
POST / PUT / DELETE / GET / GET /tombstones
to your existing HTTP API

The outbox, inbox cursors, and tombstone cursors all live in the Shiny key/value repository (SQLite on native, LocalStorage on Blazor). Operations are persisted before any network call — so a process kill mid-send leaves the operation in the queue, ready to retry. ISyncTransport is the wire-level seam: replace it for gRPC, GraphQL, or any custom protocol. See Custom Transports.

A full reference server + client live in this repo:

  • Sample.Api — minimal ASP.NET API speaking the default RestSyncTransport wire shapes for outbox, batched outbox, paginated cursor pulls, and tombstones. Backed by Shiny.DocumentDb.Sqlite for storage.
  • Sample.Maui — MAUI consumer demonstrating AddDataSync, the delegate pattern, and live Activity event observation.
claude plugin marketplace add shinyorg/skills
claude plugin install shiny-client@shiny
copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny-client@shiny
View shiny-client Plugin