skills/powersync/references/sdks/powersync-dotnet.md

19 KiB
Raw Blame History

name description metadata
powersync-dotnet PowerSync .NET SDK — schema, queries, sync lifecycle, and backend connectors
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

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:

dotnet add package PowerSync.Common --prerelease

For MAUI projects (also requires PowerSync.Common):

dotnet add package PowerSync.Maui --prerelease
dotnet add package PowerSync.Common --prerelease

For .NET Framework 4.8 compatibility, add to your .csproj:

<PropertyGroup>
  <RuntimeIdentifiers>win-x86;win-x64</RuntimeIdentifiers>
  <RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="System.Net.Http" Version="4.3.4" />
</ItemGroup>

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

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

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

// Local-only — not synced, not uploaded, persists across restarts
var drafts = new Table("drafts",
    new Dictionary<string, ColumnType> { ["content"] = ColumnType.Text },
    new TableOptions(localOnly: true));

// Insert-only — writes uploaded, server never sends data back
var logs = new Table("logs",
    new Dictionary<string, ColumnType> { ["message"] = ColumnType.Text },
    new TableOptions(insertOnly: true));

// Track previous values — available as entry.PreviousValues in UploadData
var todos = new Table("todos",
    new Dictionary<string, ColumnType>
    {
        ["description"] = ColumnType.Text,
        ["completed"] = ColumnType.Integer,
    },
    new TableOptions(
        trackPreviousValues: new TrackPreviousOptions
        {
            Columns = new List<string> { "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<string, ColumnType> { ["title"] = ColumnType.Text },
    new TableOptions(trackMetadata: true));

// Ignore no-op updates (UPDATE that changes no values)
var items = new Table("items",
    new Dictionary<string, ColumnType> { ["name"] = ColumnType.Text },
    new TableOptions(ignoreEmptyUpdates: true));

With attribute syntax:

[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

using PowerSync.Common.Client;
using PowerSync.Common.Client.Connection;
using PowerSync.Common.DB.Crud;

public class MyConnector : IPowerSyncBackendConnector
{
    public async Task<PowerSyncCredentials?> 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

entry.Id              // string — row ID
entry.Op              // UpdateType — PUT | PATCH | DELETE
entry.OpData          // Dictionary<string, object>? — changed columns (null for DELETE)
entry.Table           // string — table name
entry.TransactionId   // long? — groups ops from the same WriteTransaction()
entry.PreviousValues  // Dictionary<string, string?>? — 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

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

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

await db.Connect(connector, new PowerSyncConnectionOptions(
    @params: new Dictionary<string, object> { ["userId"] = "abc123" }, // sync parameters
    retryDelayMs: 5000,
    crudUploadThrottleMs: 1000,
    appMetadata: new Dictionary<string, string> { ["app_version"] = "1.0.0" }
));

Disconnect and Clear

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 for result mapping. Query results are mapped to record types, classes, or dynamic.

Watch Queries (Reactive)

Watch returns an IAsyncEnumerable<T[]> that emits new results whenever dependent tables change.

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<TodoResult>(
    "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:

var cts = new CancellationTokenSource();
var watcher = db.Watch<TodoResult>(
    "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:

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

// GetAll — returns T[]
var todos = await db.GetAll<TodoResult>(
    "SELECT * FROM todos WHERE list_id = ?", [listId]);

// Get — returns T, throws if not found
var todo = await db.Get<TodoResult>(
    "SELECT * FROM todos WHERE id = ?", [id]);

// GetOptional — returns T? (null if not found)
var todo = await db.GetOptional<TodoResult>(
    "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:

// 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<ListResult>("SELECT * FROM lists");

With attribute syntax, [Column("column_name")] maps columns to C# properties and Dapper handles the mapping automatically:

// Using the attributed TodoItem class defined in the schema section
var items = await db.GetAll<TodoItem>("SELECT * FROM todos");

Writes and Transactions

// 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<int>(async tx =>
{
    await tx.Execute("INSERT INTO lists (id, name, owner_id) VALUES (uuid(), ?, ?)", ["New", userId]);
    var result = await tx.Get<CountResult>("SELECT count(*) as count FROM lists");
    return result.count;
});

ID generation — every table has id TEXT PRIMARY KEY added automatically:

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

// 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

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:

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.

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.01.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 for server-side configuration (YAML definitions, parameters, CTEs) and Client-Side Usage 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:

using PowerSync.Common.Client.Sync.Stream;

// Create a stream handle
ISyncStream stream = db.SyncStream(
    name: "my_orders",
    parameters: new Dictionary<string, object> { ["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:

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):

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.