--- name: powersync-dotnet description: PowerSync .NET SDK — schema, queries, sync lifecycle, and backend connectors metadata: tags: dotnet, csharp, maui, wpf, console, sqlite, offline-first --- # PowerSync .NET SDK > **Load this when** building a .NET app (MAUI, WPF, Console) with PowerSync. ## Table of Contents - [Installation](#installation) - [Quick Setup](#quick-setup) - [Query Patterns](#query-patterns) - [Writes and Transactions](#writes-and-transactions) - [Sync Status](#sync-status) - [Sync Streams](#sync-streams) - [Logging](#logging) - [Schema Updates](#schema-updates) Best practices for building apps with the PowerSync .NET SDK. Supported targets: .NET 9, .NET 8, .NET 6, .NET Standard 2.0, .NET Framework 4.8. Application frameworks: MAUI (iOS, Android, Windows), WPF, Console. > **Alpha status** — this SDK is currently in alpha. Expect breaking changes and instability. Do not rely on it for production use. ## Installation For console, WPF, or general .NET projects: ```bash dotnet add package PowerSync.Common --prerelease ``` For MAUI projects (also requires `PowerSync.Common`): ```bash dotnet add package PowerSync.Maui --prerelease dotnet add package PowerSync.Common --prerelease ``` For .NET Framework 4.8 compatibility, add to your `.csproj`: ```xml win-x86;win-x64 win-x64 ``` ## Quick Setup ### 1. Define Schema There are two ways to define schemas: **dictionary syntax** (simple) and **attribute syntax** (type-safe, with Dapper integration). #### Dictionary Syntax ```csharp using PowerSync.Common.DB.Schema; var todos = new Table { Name = "todos", Columns = { ["description"] = ColumnType.Text, ["list_id"] = ColumnType.Text, ["completed"] = ColumnType.Integer, // booleans as INTEGER (0/1) ["created_at"] = ColumnType.Text, // dates as ISO TEXT }, Indexes = { ["list_idx"] = ["list_id"], } }; var lists = new Table { Name = "lists", Columns = { ["name"] = ColumnType.Text, ["owner_id"] = ColumnType.Text, ["created_at"] = ColumnType.Text, } }; var schema = new Schema(todos, lists); ``` #### Attribute Syntax ```csharp using PowerSync.Common.DB.Schema.Attributes; [Table("todos"), Index("list_idx", ["list_id"])] public class TodoItem { [Column("id")] public string Id { get; set; } = ""; [Column("description")] public string Description { get; set; } = ""; [Column("list_id")] public string ListId { get; set; } = ""; [Column("completed")] public bool Completed { get; set; } // bool maps to INTEGER automatically [Column("created_at")] public string CreatedAt { get; set; } = ""; } [Table("lists")] public class TodoList { [Column("id")] public string Id { get; set; } = ""; [Column("name")] public string Name { get; set; } = ""; [Column("owner_id")] public string OwnerId { get; set; } = ""; [Column("created_at")] public string CreatedAt { get; set; } = ""; } // Build schema from attributed types var schema = new Schema(typeof(TodoItem), typeof(TodoList)); // Or mix: new Table(typeof(TodoItem)) also works ``` With attribute syntax, an `id` property of type `string` is **required** on each class but is not added as a column — PowerSync adds `id TEXT PRIMARY KEY` automatically. The `[Column("id")]` attribute maps the property for Dapper deserialization. Column types: `ColumnType.Text`, `ColumnType.Integer`, `ColumnType.Real` only — no boolean, date, or JSON native types. With attributes, `ColumnType.Inferred` auto-maps C# types (`string` → Text, `bool`/`int`/`long`/`enum` → Integer, `float`/`double` → Real, `decimal`/`DateTime`/`Guid` → Text). No migrations required. Schema changes apply on next open. New columns start null; removed columns become inaccessible (data remains). Renaming = add new + remove old (data loss). ### Special Table Options ```csharp // Local-only — not synced, not uploaded, persists across restarts var drafts = new Table("drafts", new Dictionary { ["content"] = ColumnType.Text }, new TableOptions(localOnly: true)); // Insert-only — writes uploaded, server never sends data back var logs = new Table("logs", new Dictionary { ["message"] = ColumnType.Text }, new TableOptions(insertOnly: true)); // Track previous values — available as entry.PreviousValues in UploadData var todos = new Table("todos", new Dictionary { ["description"] = ColumnType.Text, ["completed"] = ColumnType.Integer, }, new TableOptions( trackPreviousValues: new TrackPreviousOptions { Columns = new List { "completed" }, // null = track all columns OnlyWhenChanged = true } )); // Track write metadata — adds _metadata column, available as entry.Metadata in UploadData var tasks = new Table("tasks", new Dictionary { ["title"] = ColumnType.Text }, new TableOptions(trackMetadata: true)); // Ignore no-op updates (UPDATE that changes no values) var items = new Table("items", new Dictionary { ["name"] = ColumnType.Text }, new TableOptions(ignoreEmptyUpdates: true)); ``` With attribute syntax: ```csharp [Table("todos", TrackPreviousValues = TrackPrevious.Columns | TrackPrevious.OnlyWhenChanged)] [Index("list_idx", ["list_id"])] public class TodoItem { [Column("id")] public string Id { get; set; } = ""; [Column("completed", TrackPrevious = true)] // only this column tracked public bool Completed { get; set; } [Column("description")] public string Description { get; set; } = ""; } [Table("logs", InsertOnly = true)] public class LogEntry { /* ... */ } [Table("drafts", LocalOnly = true)] public class Draft { /* ... */ } [Table("tasks", TrackMetadata = true)] public class Task { /* ... */ } [Table("items", IgnoreEmptyUpdates = true)] public class Item { /* ... */ } ``` ### 2. Create Backend Connector ```csharp using PowerSync.Common.Client; using PowerSync.Common.Client.Connection; using PowerSync.Common.DB.Crud; public class MyConnector : IPowerSyncBackendConnector { public async Task FetchCredentials() { // Always fetch fresh credentials — do not cache stale tokens var token = await myAuthService.GetTokenAsync(); return new PowerSyncCredentials( endpoint: "https://your-instance.powersync.journeyapps.com", token: token ); } public async Task UploadData(IPowerSyncDatabase database) { var transaction = await database.GetNextCrudTransaction(); if (transaction == null) return; try { foreach (var entry in transaction.Crud) { switch (entry.Op) { case UpdateType.PUT: await api.Upsert(entry.Table, entry.Id, entry.OpData!); break; case UpdateType.PATCH: await api.Update(entry.Table, entry.Id, entry.OpData!); break; case UpdateType.DELETE: await api.Delete(entry.Table, entry.Id); break; } } await transaction.Complete(); // MUST call — otherwise the same transaction is returned forever } catch (Exception ex) { throw; // PowerSync backs off and retries automatically } } } ``` **Fatal upload errors**: If `UploadData` always throws for a bad record, the queue stalls permanently. Detect unrecoverable errors (e.g. constraint violations) and call `await transaction.Complete()` to discard them. #### CrudEntry Fields ```csharp entry.Id // string — row ID entry.Op // UpdateType — PUT | PATCH | DELETE entry.OpData // Dictionary? — changed columns (null for DELETE) entry.Table // string — table name entry.TransactionId // long? — groups ops from the same WriteTransaction() entry.PreviousValues // Dictionary? — requires TrackPreviousValues on table entry.Metadata // string? — requires TrackMetadata on table entry.ClientId // int — internal client ID for the CRUD entry ``` Op semantics: `PUT` = full insert/replace (all non-null columns), `PATCH` = partial update (changed columns only), `DELETE` = deletion (`OpData` is null). For batching multiple transactions at once, use `database.GetCrudBatch(limit)`. Both `CrudBatch` and `CrudTransaction` follow the same `Complete()` contract. ### 3. Initialize and Connect #### Console / WPF ```csharp using PowerSync.Common.Client; var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions { Database = new SQLOpenOptions { DbFilename = "app.db" }, Schema = schema, }); await db.Init(); // Starts sync stream and UploadData loop in the background (non-blocking) await db.Connect(connector); // Wait for first sync before showing data-dependent UI await db.WaitForFirstSync(); ``` #### MAUI ```csharp using PowerSync.Common.Client; using PowerSync.Common.MDSQLite; using PowerSync.Maui.SQLite; var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db"); var factory = new MAUISQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions { DbFilename = dbPath }); var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions { Database = factory, Schema = schema, }); await db.Init(); await db.Connect(connector); ``` Use a **single `PowerSyncDatabase` instance** per database file. Multiple instances for the same file cause lock contention and missed watch updates — share via dependency injection. #### Connection Options ```csharp await db.Connect(connector, new PowerSyncConnectionOptions( @params: new Dictionary { ["userId"] = "abc123" }, // sync parameters retryDelayMs: 5000, crudUploadThrottleMs: 1000, appMetadata: new Dictionary { ["app_version"] = "1.0.0" } )); ``` #### Disconnect and Clear ```csharp await db.Disconnect(); // stop syncing, keep local data await db.DisconnectAndClear(); // stop syncing, wipe synced tables (e.g. on sign-out) await db.DisconnectAndClear(clearLocal: false); // wipe synced data but preserve local-only tables await db.Close(); // release resources — cannot be reused after this ``` `Disconnect()` — temporary offline, token refresh, app backgrounding. Safe to reconnect as the same user. `DisconnectAndClear()` — user sign-out or account switch, prevents stale data leaking to the next user. `Close()` — app termination, instance cannot be reused after this call. ## Query Patterns The .NET SDK uses [Dapper](https://github.com/DapperLib/Dapper) for result mapping. Query results are mapped to record types, classes, or `dynamic`. ### Watch Queries (Reactive) Watch returns an `IAsyncEnumerable` that emits new results whenever dependent tables change. ```csharp public record TodoResult(string id, string description, string list_id, int completed); // Watch returns IAsyncEnumerable — call synchronously to capture table listener var watcher = db.Watch( "SELECT * FROM todos WHERE list_id = ? ORDER BY id", parameters: [listId] ); // Consume asynchronously await foreach (var todos in watcher) { // todos is TodoResult[] — updated on every relevant table change UpdateUI(todos); } ``` With cancellation: ```csharp var cts = new CancellationTokenSource(); var watcher = db.Watch( "SELECT * FROM todos", options: new SQLWatchOptions { Signal = cts.Token } ); _ = Task.Run(async () => { await foreach (var results in watcher) { UpdateUI(results); } }, cts.Token); // Later: stop watching cts.Cancel(); ``` React to table changes without re-running a query: ```csharp var onChange = db.OnChange(new SQLWatchOptions { Tables = ["todos", "lists"] }); await foreach (var e in onChange) { Console.WriteLine($"Changed tables: {string.Join(", ", e.ChangedTables)}"); } ``` ### One-Time Queries ```csharp // GetAll — returns T[] var todos = await db.GetAll( "SELECT * FROM todos WHERE list_id = ?", [listId]); // Get — returns T, throws if not found var todo = await db.Get( "SELECT * FROM todos WHERE id = ?", [id]); // GetOptional — returns T? (null if not found) var todo = await db.GetOptional( "SELECT * FROM todos WHERE id = ?", [id]); // Dynamic results (no type parameter) var rows = await db.GetAll("SELECT * FROM todos"); ``` ### Result Mapping With dictionary syntax, use records/classes whose property names match column names: ```csharp // Simple record — property names must match column names exactly public record ListResult(string id, string name, string owner_id, string created_at); var lists = await db.GetAll("SELECT * FROM lists"); ``` With attribute syntax, `[Column("column_name")]` maps columns to C# properties and Dapper handles the mapping automatically: ```csharp // Using the attributed TodoItem class defined in the schema section var items = await db.GetAll("SELECT * FROM todos"); ``` ## Writes and Transactions ```csharp // Single operation — uuid() is a PowerSync built-in SQLite function await db.Execute( "INSERT INTO lists (id, created_at, name, owner_id) VALUES (uuid(), datetime(), ?, ?)", ["My List", userId]); // Multiple operations atomically — auto-commits, auto-rollbacks on exception await db.WriteTransaction(async tx => { await tx.Execute("DELETE FROM lists WHERE id = ?", [listId]); await tx.Execute("DELETE FROM todos WHERE list_id = ?", [listId]); }); // Write transaction with return value var count = await db.WriteTransaction(async tx => { await tx.Execute("INSERT INTO lists (id, name, owner_id) VALUES (uuid(), ?, ?)", ["New", userId]); var result = await tx.Get("SELECT count(*) as count FROM lists"); return result.count; }); ``` ID generation — every table has `id TEXT PRIMARY KEY` added automatically: ```csharp await db.Execute("INSERT INTO todos (id, description) VALUES (uuid(), ?)", ["Buy milk"]); // or generate in C#: await db.Execute("INSERT INTO todos (id, description) VALUES (?, ?)", [Guid.NewGuid().ToString(), "Buy milk"]); ``` ### Batch Execution ```csharp // Execute the same statement with multiple parameter sets await db.ExecuteBatch( "INSERT INTO todos (id, description, list_id) VALUES (uuid(), ?, ?)", [ ["Buy milk", listId], ["Buy eggs", listId], ["Buy bread", listId], ]); ``` ## Sync Status ```csharp SyncStatus status = db.CurrentStatus; status.Connected // bool — sync stream is active status.Connecting // bool status.DataFlowStatus.Downloading // bool status.DataFlowStatus.Uploading // bool status.HasSynced // bool? — null = DB still opening; true = at least one full sync completed status.LastSyncedAt // DateTime? status.DownloadProgress() // SyncProgress? — non-null only while downloading status.DataFlowStatus.UploadError // Exception? status.DataFlowStatus.DownloadError // Exception? ``` Observe status changes via event stream: ```csharp await foreach (var update in db.ListenAsync(cancellationToken)) { if (update.StatusChanged != null) { var s = update.StatusChanged; Console.WriteLine($"Connected: {s.Connected}, HasSynced: {s.HasSynced}"); } } ``` `HasSynced` persists across app restarts once set. ### Sync Priorities Buckets can be assigned priorities (lower number = higher priority) on the server. Higher-priority data syncs first. See [Prioritized Sync](https://docs.powersync.com/usage/use-case-examples/prioritized-sync.md). ```csharp await db.WaitForFirstSync(new PowerSyncDatabase.PrioritySyncRequest { Priority = 1 }); var entry = db.CurrentStatus.StatusForPriority(1); entry.HasSynced // bool? entry.LastSyncedAt // DateTime? var progress = db.CurrentStatus.DownloadProgress()?.UntilPriority(1); progress?.DownloadedFraction // double 0.0–1.0 progress?.DownloadedOperations // int progress?.TotalOperations // int ``` ## Sync Streams Sync Streams are the recommended way to define what data syncs to each client. See [Sync Config](references/sync-config.md) for server-side configuration (YAML definitions, parameters, CTEs) and [Client-Side Usage](https://docs.powersync.com/sync/streams/client-usage.md) for full examples. > Sync Streams are currently in alpha in the .NET SDK. If `auto_subscribe` is not set to `true` in the sync config, subscribe to streams from client code: ```csharp using PowerSync.Common.Client.Sync.Stream; // Create a stream handle ISyncStream stream = db.SyncStream( name: "my_orders", parameters: new Dictionary { ["userId"] = currentUserId } ); // Subscribe to the stream ISyncStreamSubscription subscription = await stream.Subscribe(new SyncStreamSubscribeOptions { Ttl = TimeSpan.FromHours(1), // optional — keep data alive after unsubscribe Priority = new StreamPriority(1), // optional — lower number = higher priority }); // Wait for this specific stream to complete its first sync await subscription.WaitForFirstSync(); // Check stream status SyncStreamStatus? streamStatus = db.CurrentStatus.ForStream(subscription); streamStatus?.Subscription.HasSynced // bool streamStatus?.Subscription.LastSyncedAt // DateTime? streamStatus?.Subscription.Active // bool streamStatus?.Subscription.IsDefault // bool streamStatus?.Subscription.ExpiresAt // DateTime? streamStatus?.Progress?.DownloadedFraction // double // Unsubscribe when done — TTL starts running after this subscription.Unsubscribe(); // Or unsubscribe all subscriptions for a stream await stream.UnsubscribeAll(); ``` Same stream name with different parameters creates separate subscriptions. Subscribing while offline is supported — subscriptions are tracked locally and sent on next connect. ## Logging The SDK accepts an `ILogger` via `PowerSyncDatabaseOptions`: ```csharp using Microsoft.Extensions.Logging; ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); builder.SetMinimumLevel(LogLevel.Debug); }); var logger = loggerFactory.CreateLogger("PowerSync"); var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions { Database = new SQLOpenOptions { DbFilename = "app.db" }, Schema = schema, Logger = logger, }); ``` ## Schema Updates Update the schema at runtime (must be disconnected): ```csharp await db.Disconnect(); await db.UpdateSchema(newSchema); await db.Connect(connector); ``` ## Additional Resources Only read these if the content above does not provide enough context for the task. - [NuGet packages](https://www.nuget.org/profiles/PowerSync) — published packages - [Full SDK reference](https://docs.powersync.com/client-sdk-references/dotnet.md) — full SDK documentation - [CommandLine](https://github.com/powersync-ja/powersync-dotnet/tree/main/demos/CommandLine) — CLI app with real-time data sync example - [WPF](https://github.com/powersync-ja/powersync-dotnet/tree/main/demos/WPF) — Windows desktop to-do list app example - [MAUITodo](https://github.com/powersync-ja/powersync-dotnet/tree/main/demos/MAUITodo) — Cross-platform mobile and desktop to-do list example