Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/dev-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
dotnet-version: "10.0.x"

- name: Install MAUI Workloads
run: dotnet workload restore
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
dotnet-version: "10.0.x"

- name: Install MAUI Workloads
run: dotnet workload restore
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: Setup .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: '8.0'
dotnet-version: '10.0'

- name: Install MAUI Workloads
run: dotnet workload restore
Expand All @@ -30,4 +30,4 @@ jobs:
run: dotnet restore

- name: Run tests
run: dotnet test -v n --framework net8.0
run: dotnet test -v n --framework net10.0
4 changes: 0 additions & 4 deletions Directory.build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,5 @@
<Project>
<PropertyGroup>
<MSBuildWarningsAsMessages>$(MSBuildWarningsAsMessages);NETSDK1202</MSBuildWarningsAsMessages>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)IsExternalInit.cs" Visible="false" />
</ItemGroup>
</Project>
7 changes: 0 additions & 7 deletions IsExternalInit.cs

This file was deleted.

8 changes: 8 additions & 0 deletions PowerSync/PowerSync.Common/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@

- Accidental release (mirror of 0.1.2). Use 1.0.1 instead.

## 0.1.5

- Update the PowerSync SQLite core extension to 0.5.3.
- Fix a transaction affecting both insert-only and regular tables being recorded as two transaction ids.
- Improve error messages for errors originating from the core extension.
- Add `PowerSyncConnectionOptions.CheckpointMode`. When set to `CheckpointMode.Requests()`, the connection manager uses a new protocol for checkpoints after crud uploads with better support for switching users.
- Add `PowerSyncDatabase.RequestCheckpoint()`, which can be used to request sync updates explicitly and wait for those to complete.

## 0.1.4

- Update the PowerSync SQLite core extension to 0.5.2.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,24 @@ public interface IPowerSyncBackendConnector
/// </summary>
Task UploadData(IPowerSyncDatabase database);
}

/// <summary>
/// An <see cref="IPowerSyncBackendConnector" /> capable of requesting checkpoints.
///
/// Extend this class instead of <see cref="IPowerSyncBackendConnector" /> when uploads are processed
/// asynchronously by the backend (for example through a message queue): The sync client as part of
/// the PowerSync .NET SDK generates a checkpoint request id and hands it to your backend via this
/// class, which is responsible for creating a matching checkpoint once the uploads preceding the
/// request have been processed.
/// For more details, see <see href="https://docs.powersync.com/client-sdks/advanced/checkpoint-requests#asynchronous-upload-backends">asynchronous backend uploads</see>.
///
/// To use this connector, using <see cref="Sync.Stream.CheckpointMode.Requests" /> is required. Note that
/// this requires PowerSync service version 1.24.0 or later.
/// </summary>
public interface ICustomCheckpointRequestConnector : IPowerSyncBackendConnector
{
/// <summary>
/// Posts a client-generated checkpoint request to the backend and returns the effective checkpoint request state.
/// </summary>
Task<long> PostCheckpointRequest(string clientId, long requestId, CancellationToken token);
}
5 changes: 5 additions & 0 deletions PowerSync/PowerSync.Common/Client/ConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ public async Task Connect(IPowerSyncBackendConnector connector, PowerSyncConnect
// Update pending options to the latest values
PendingConnectionOptions = new StoredConnectionOptions(connector, options);

// Warn if connector expects checkpoint requests but write checkpoints are enabled
if (connector is ICustomCheckpointRequestConnector && PendingConnectionOptions.Options.CheckpointMode == CheckpointMode.Legacy)
{
Logger.LogWarning("The backend connector implements ICustomCheckpointRequestConnector, but Connect() was called without checkpoint requests enabled.");
}

// Disconnecting here provides aborting in progress connection attempts.
// The ConnectInternal method will clear pending options once it starts connecting (with the options).
Expand Down
82 changes: 70 additions & 12 deletions PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace PowerSync.Common.Client;
using Nito.AsyncEx;

using PowerSync.Common.Client.Connection;
using PowerSync.Common.Client.Sync;
using PowerSync.Common.Client.Sync.Bucket;
using PowerSync.Common.Client.Sync.Stream;
using PowerSync.Common.DB;
Expand Down Expand Up @@ -50,6 +51,12 @@ public class PowerSyncDatabaseOptions() : BasePowerSyncDatabaseOptions()
/// If not provided, a default Remote will be created.
/// </summary>
public Func<IPowerSyncBackendConnector, Remote>? RemoteFactory { get; set; }

/// <summary>
/// Used to calculate delays for the sync client (retry delays, upload throttling).
/// Used for testing to avoid waiting out some long delays (retry delay is minimum 10 seconds).
/// </summary>
internal TimeProvider? TimeProvider { get; set; }
}

public class PowerSyncDBEvents : EventManager
Expand Down Expand Up @@ -200,14 +207,15 @@ public PowerSyncDatabase(PowerSyncDatabaseOptions options)
SdkVersion = "";

remoteFactory = options.RemoteFactory ?? (connector => new Remote(connector));
var timeProvider = options.TimeProvider ?? TimeProvider.System;

watchManager = new WatchManager(this, masterCts.Token);

// Start async init
subscriptions = new InternalSubscriptionManager(
firstStatusMatching: WaitForStatus,
resolveOfflineSyncStatus: ResolveOfflineSyncStatus,
subscriptionsCommand: async (payload) => await this.WriteTransaction(async tx =>
subscriptionsCommand: async (payload) => await WriteTransaction(async tx =>
{
await tx.Execute("SELECT powersync_control(?, ?) AS r", ["subscriptions", JsonConvert.SerializeObject(payload)]);
}));
Expand All @@ -226,9 +234,17 @@ public PowerSyncDatabase(PowerSyncDatabaseOptions options)
await WaitForReady();
await connector.UploadData(this);
},
PostCheckpointRequest = connector is ICustomCheckpointRequestConnector checkpointConnector
? async (clientId, requestId, token) =>
{
await WaitForReady();
return await checkpointConnector.PostCheckpointRequest(clientId, requestId, token);
}
: null,
RetryDelayMs = options.RetryDelayMs,
Subscriptions = options.Subscriptions,
CrudUploadThrottleMs = options.CrudUploadThrottleMs,
TimeProvider = timeProvider,
Logger = Logger
});

Expand Down Expand Up @@ -319,6 +335,7 @@ public async Task WaitForStatus(Func<SyncStatus, bool> predicate, CancellationTo
}

var tcs = new TaskCompletionSource<bool>();
var canceledRegistration = cts.Token.Register(() => tcs.TrySetCanceled(cts.Token));

_ = Task.Run(async () =>
{
Expand All @@ -334,9 +351,22 @@ public async Task WaitForStatus(Func<SyncStatus, bool> predicate, CancellationTo
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
tcs.TrySetException(ex);
cts.Cancel();
}
});

await tcs.Task;
try
{
await tcs.Task;
}
finally
{
canceledRegistration.Dispose();
cts.Cancel();
}
}

protected async Task Initialize(PowerSyncDatabaseOptions options)
Expand Down Expand Up @@ -451,16 +481,6 @@ public async Task Init()
await WaitForReady();
}

private RequiredAdditionalConnectionOptions resolveConnectionOptions(PowerSyncConnectionOptions? options)
{
var defaults = RequiredAdditionalConnectionOptions.DEFAULT_ADDITIONAL_CONNECTION_OPTIONS;
return new RequiredAdditionalConnectionOptions
{
RetryDelayMs = options?.RetryDelayMs ?? defaults.RetryDelayMs,
CrudUploadThrottleMs = options?.CrudUploadThrottleMs ?? defaults.CrudUploadThrottleMs,
};
}

public async Task Connect(IPowerSyncBackendConnector connector, PowerSyncConnectionOptions? options = null)
{
await WaitForReady();
Expand Down Expand Up @@ -506,6 +526,41 @@ await Database.WriteTransaction(async tx =>
Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus));
}

/// <summary>
/// Requests a checkpoint from the PowerSync service.
///
/// The returned request can be awaited using <see cref="CheckpointRequest.WaitForSync" />
/// to confirm that the local database has applied server-side changes up to
/// the checkpoint. This method requires an active or connecting sync client
/// connected with <see cref="CheckpointMode.Requests()" /> and PowerSync service version
/// 1.24.0 or later.
/// </summary>
/// <exception cref="CheckpointRequestException">
/// Thrown when requesting the checkpoint has failed, for example when the
/// database is disconnected.
/// </exception>
public async Task<CheckpointRequest> RequestCheckpoint(CancellationToken ct = default)
{
await WaitForReady();

// Important: `LockAsync(CancellationToken)` will still take the lock even if
// cancellation is requested, so long as the token is canceled before LockAsync
// is called and the lock is not in use; i.e. an already-canceled token will only
// prevent _waiting_ for the lock, not getting the lock. Therefore, we need to
// check for cancellation ourselves before entering the using block.
ct.ThrowIfCancellationRequested();
using (await runExclusive.LockAsync(ct))
{
var sync = SyncStreamImplementation;
if (sync == null)
{
throw new CheckpointRequestException(CheckpointRequestException.Disconnected);
}

return await sync.RequestCheckpoint(this, ct);
}
}

/// <summary>
/// Create a sync stream to query its status or to subscribe to it.
///
Expand Down Expand Up @@ -829,6 +884,9 @@ public class SQLWatchOptions
/// </summary>
public int? ThrottleMs { get; set; }

/// <summary>
/// If true, runs the query once when creating the watch. Defaults to false.
/// </summary>
public bool TriggerImmediately { get; set; } = false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
public const string NOTIFY_CRUD_UPLOAD_COMPLETED = "completed_upload";
public const string UPDATE_SUBSCRIPTIONS = "update_subscriptions";

public const string TARGET_CHECKPOINT_REQUEST_ID = "target_checkpoint_request_id";

/// <summary>
/// An `established` or `end` event for response streams.
/// </summary>
Expand Down Expand Up @@ -136,9 +134,45 @@
Task<CrudBatch?> GetCrudBatch(int limit = 100);

Task<bool> UpdateLocalTarget(Func<Task<long>> callback);

Task HandleCrudCheckpoint(long lastClientId, long? writeCheckpoint = null);

/// <summary>
/// Reads or updates the local checkpoint request ID counter.
/// </summary>
Task<long?> ReadOrUpdateCheckpoint(string variant, long? update = null);

/// <summary>
/// Increments and returns the local checkpoint counter.
/// </summary>
public async Task<long> NextCheckpointRequestId() => (long)await ReadOrUpdateCheckpoint("next");

Check warning on line 147 in PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs

View workflow job for this annotation

GitHub Actions / Test Packages windows-latest

Nullable value type may be null.

Check warning on line 147 in PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs

View workflow job for this annotation

GitHub Actions / Test Packages windows-latest

Nullable value type may be null.

Check warning on line 147 in PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs

View workflow job for this annotation

GitHub Actions / Test Packages windows-11-arm

Nullable value type may be null.

Check warning on line 147 in PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs

View workflow job for this annotation

GitHub Actions / Test Packages windows-11-arm

Nullable value type may be null.

/// <summary>
/// Returns the highest checkpoint request ID that has been requested on this device.
/// </summary>
public Task<long?> CurrentCheckpointRequestId() => ReadOrUpdateCheckpoint("current");

/// <summary>
/// Seeds the local checkpoint request ID counter using a response from the server.
///
/// Seeding the local counter achieves two goals:
/// <list type="number">
/// <item>
/// <description>
/// The service is allowed to forget our checkpoint counter, so we remind
/// it whenever we connect.
/// </description>
/// </item>
/// <item>
/// <description>
/// Checkpoint requests are scoped per user-and-device combo, but the
/// local ID counter is scoped per-device. Seeding ensures we generate
/// correctly incrementing IDs after switching user accounts.
/// </description>
/// </item>
/// </list>
/// </summary>
public async Task<long> SeedCheckpointRequestId(long serviceResponse) => (long)await ReadOrUpdateCheckpoint("seed", serviceResponse);

Check warning on line 174 in PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs

View workflow job for this annotation

GitHub Actions / Test Packages windows-latest

Nullable value type may be null.

Check warning on line 174 in PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs

View workflow job for this annotation

GitHub Actions / Test Packages windows-latest

Nullable value type may be null.

Check warning on line 174 in PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs

View workflow job for this annotation

GitHub Actions / Test Packages windows-11-arm

Nullable value type may be null.

Check warning on line 174 in PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs

View workflow job for this annotation

GitHub Actions / Test Packages windows-11-arm

Nullable value type may be null.

/// <summary>
/// Get a unique client ID.
/// </summary>
Expand All @@ -148,4 +182,6 @@
/// Invokes the `powersync_control` function for the sync client.
/// </summary>
Task<string> Control(string op, object? payload);


}
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,11 @@ namespace PowerSync.Common.Client.Sync.Bucket;

using Newtonsoft.Json;

using PowerSync.Common.Client.Sync.Stream;
using PowerSync.Common.DB;
using PowerSync.Common.DB.Crud;

public class SqliteBucketStorage : IBucketStorageAdapter
{
public const long MAX_OP_ID = 9223372036854775807;

public BucketStorageEvents Events { get; } = new();

private readonly IDBAdapter db;
Expand Down Expand Up @@ -69,16 +66,25 @@ public async Task<string> GetClientId()
}

/// <summary>
/// Reads the stored target checkpoint request id, or updates it when the update parameter is set.
/// Reads or updates the stored checkpoint request id.
/// </summary>
public Task<long?> ReadOrUpdateCheckpoint(string variant, long? update = null)
=> db.WriteTransaction(tx => ReadOrUpdateCheckpoint(tx, variant, update));

/// <summary>
/// Reads or updates the stored checkpoint request id using the given transaction.
/// </summary>
/// <returns>The previous checkpoint request.</returns>
private static Task<long?> TargetCheckpointRequestId(ILockContext tx, long? update = null)
public static Task<long?> ReadOrUpdateCheckpoint(ITransaction tx, string variant, long? payload = null)
{
return tx.Get<long?>(
"SELECT powersync_control(?, ?) AS r",
[PowerSyncControlCommand.TARGET_CHECKPOINT_REQUEST_ID, update]);
[$"{variant}_checkpoint_request_id", payload]);
}

// This is called within existing transactions, therefore accept an ITransaction instead of creating a new one
private static Task<long?> TargetCheckpointRequestId(ITransaction tx, long? update = null)
=> ReadOrUpdateCheckpoint(tx, "target", update);

private record ResultResult(object result);

public class ResultDetail
Expand All @@ -97,7 +103,7 @@ public async Task<bool> UpdateLocalTarget(Func<Task<long>> callback)
var seqBeforeResult = await db.ReadTransaction(async tx =>
{
var currentTarget = await TargetCheckpointRequestId(tx);
if (currentTarget != MAX_OP_ID)
if (currentTarget != long.MaxValue)
{
// Nothing to update
return (long?)null;
Expand Down Expand Up @@ -152,6 +158,7 @@ public async Task<bool> UpdateLocalTarget(Func<Task<long>> callback)
return true;
});
}

public Task HandleCrudCheckpoint(long lastClientId, long? writeCheckpoint = null)
{
return db.WriteTransaction(async tx =>
Expand All @@ -163,7 +170,7 @@ public Task HandleCrudCheckpoint(long lastClientId, long? writeCheckpoint = null

await TargetCheckpointRequestId(
tx,
writeCheckpoint is not null && !crudRemaining ? writeCheckpoint : MAX_OP_ID);
writeCheckpoint is not null && !crudRemaining ? writeCheckpoint : long.MaxValue);
});
}

Expand Down
Loading
Loading