From bf31869d48812bea9a8f8aa80f14c889ab40f14c Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 19 May 2025 10:08:14 +0200 Subject: [PATCH 1/8] Added a few adapter tests. Bumped core extension version used. --- ...tionTests.cs => PowerSyncDatabaseTests.cs} | 63 ++++++++++++++++++- Tools/Setup/Setup.cs | 2 +- 2 files changed, 62 insertions(+), 3 deletions(-) rename Tests/PowerSync/PowerSync.Common.Tests/Client/{PowerSyncDatabaseTransactionTests.cs => PowerSyncDatabaseTests.cs} (85%) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTransactionTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs similarity index 85% rename from Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTransactionTests.cs rename to Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index f56cbc3..b2d6dde 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTransactionTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -1,9 +1,11 @@ namespace PowerSync.Common.Tests.Client; +using Microsoft.Data.Sqlite; + using System.Diagnostics; using PowerSync.Common.Client; -public class PowerSyncDatabaseTransactionTests : IAsyncLifetime +public class PowerSyncDatabaseTests : IAsyncLifetime { private PowerSyncDatabase db = default!; @@ -27,6 +29,63 @@ private record IdResult(string id); private record AssetResult(string id, string description, string? make = null); private record CountResult(int count); + [Fact] + public async Task QueryWithoutParamsTest() + { + var name = "Test User"; + var age = 30; + + await db.Execute( + "INSERT INTO assets(id, description, make) VALUES(?, ?, ?)", + [Guid.NewGuid().ToString(), name, age.ToString()] + ); + + var result = await db.GetAll("SELECT id, description, make FROM assets"); + + Assert.Single(result); + var row = result.First(); + Assert.Equal(name, row.description); + Assert.Equal(age.ToString(), row.make); + } + + [Fact] + public async Task QueryWithParamsTest() + { + var id = Guid.NewGuid().ToString(); + var name = "Test User"; + var age = 30; + + await db.Execute( + "INSERT INTO assets(id, description, make) VALUES(?, ?, ?)", + [id, name, age.ToString()] + ); + + var result = await db.GetAll("SELECT id, description, make FROM assets WHERE id = ?", [id]); + + Assert.Single(result); + var row = result.First(); + Assert.Equal(id, row.id); + Assert.Equal(name, row.description); + Assert.Equal(age.ToString(), row.make); + } + + [Fact] + public async Task FailedInsertTest() + { + var name = "Test User"; + var age = 30; + + var exception = await Assert.ThrowsAsync(async () => + { + await db.Execute( + "INSERT INTO assetsfail (id, description, make) VALUES(?, ?, ?)", + [Guid.NewGuid().ToString(), name, age.ToString()] + ); + }); + + Assert.Contains("no such table", exception.Message); + } + [Fact] public async Task SimpleReadTransactionTest() { @@ -334,7 +393,7 @@ public async Task Insert1000Records_CompleteWithinTimeLimitTest() int n = random.Next(0, 100000); await db.Execute( "INSERT INTO assets(id, description) VALUES(?, ?)", - [i + 1, n] + [(i + 1).ToString(), n] ); } diff --git a/Tools/Setup/Setup.cs b/Tools/Setup/Setup.cs index 5494705..6f53724 100644 --- a/Tools/Setup/Setup.cs +++ b/Tools/Setup/Setup.cs @@ -8,7 +8,7 @@ public class Setup { static async Task Main(string[] args) { - const string baseUrl = "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v0.3.8"; + const string baseUrl = "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v0.3.14"; string powersyncCorePath = Path.Combine(AppContext.BaseDirectory, "../../../../..", "PowerSync/PowerSync.Common/"); var runtimeIdentifiers = new Dictionary From e9144fc4996bcac2bfe08eb6eb2da4a3891ea080 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 19 May 2025 11:20:35 +0200 Subject: [PATCH 2/8] Pulling lastSyncedAt from sync_state table. --- .../Client/PowerSyncDatabase.cs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index 60cd033..e79388d 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -85,6 +85,7 @@ public interface IPowerSyncDatabase : IEventStream public class PowerSyncDatabase : EventStream, IPowerSyncDatabase { + private static readonly int FULL_SYNC_PRIORITY = 2147483647; public IDBAdapter Database; private Schema schema; @@ -231,21 +232,34 @@ private async Task LoadVersion() } } - private record LastSyncedResult(string? synced_at); + private record LastSyncedResult(int? priority, string? last_synced_at); protected async Task UpdateHasSynced() { - var result = await Database.Get("SELECT powersync_last_synced_at() as synced_at"); + var results = await Database.GetAll( + "SELECT priority, last_synced_at FROM ps_sync_state ORDER BY priority DESC" + ); - var hasSynced = result.synced_at != null; - DateTime? syncedAt = result.synced_at != null ? DateTime.Parse(result.synced_at + "Z") : null; + DateTime? lastCompleteSync = null; + foreach (var result in results) + { + var parsedDate = DateTime.Parse(result.last_synced_at + "Z"); + + if (result.priority == FULL_SYNC_PRIORITY) + { + // This lowest-possible priority represents a complete sync. + lastCompleteSync = parsedDate; + } + } + + var hasSynced = lastCompleteSync != null; if (hasSynced != CurrentStatus.HasSynced) { CurrentStatus = new SyncStatus(new SyncStatusOptions(CurrentStatus.Options) { HasSynced = hasSynced, - LastSyncedAt = syncedAt + LastSyncedAt = lastCompleteSync, }); Emit(new PowerSyncDBEvent { StatusChanged = CurrentStatus }); From ec51dd2c05edb2b49b5d82ab57c89a765dc1e41b Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 19 May 2025 13:18:41 +0200 Subject: [PATCH 3/8] Credentials invalidation. --- .../Client/Sync/Stream/Remote.cs | 50 +++++++++++++++++-- .../Stream/StreamingSyncImplementation.cs | 6 +++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/Remote.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/Remote.cs index 8263ab4..3b6d8dc 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/Remote.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/Remote.cs @@ -6,6 +6,7 @@ namespace PowerSync.Common.Client.Sync.Stream; using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Text.RegularExpressions; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -29,7 +30,6 @@ public class RequestDetails public class Remote { - private static int REFRESH_CREDENTIALS_SAFETY_PERIOD_MS = 30_000; private readonly HttpClient httpClient; protected IPowerSyncBackendConnector connector; @@ -41,18 +41,48 @@ public Remote(IPowerSyncBackendConnector connector) this.connector = connector; } + /// + /// Get credentials currently cached, or fetch new credentials if none are available. + /// These credentials may have expired already. + /// public async Task GetCredentials() { - if (credentials?.ExpiresAt > DateTime.Now.AddMilliseconds(REFRESH_CREDENTIALS_SAFETY_PERIOD_MS)) + if (credentials != null) { return credentials; } + return await PrefetchCredentials(); + } - credentials = await connector.FetchCredentials(); - + /// + /// Fetch a new set of credentials and cache it. + /// Until this call succeeds, GetCredentials will still return the old credentials. + /// This may be called before the current credentials have expired. + /// + public async Task PrefetchCredentials() + { + credentials = await FetchCredentials(); return credentials; } + /// + /// Get credentials for PowerSync. + /// This should always fetch a fresh set of credentials - don't use cached values. + /// + public async Task FetchCredentials() + { + return await connector.FetchCredentials(); + } + + /// + /// Immediately invalidate credentials. + /// This may be called when the current credentials have expired. + /// + public void InvalidateCredentials() + { + credentials = null; + } + static string GetUserAgent() { object[] attributes = Assembly.GetExecutingAssembly() @@ -76,6 +106,11 @@ public async Task Get(string path, Dictionary? headers = n using var client = new HttpClient(); var response = await client.SendAsync(request); + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + InvalidateCredentials(); + } + if (!response.IsSuccessStatusCode) { var errorMessage = await response.Content.ReadAsStringAsync(); @@ -95,7 +130,12 @@ public async Task Get(string path, Dictionary? headers = n { throw new HttpRequestException($"HTTP {response.StatusCode}: No content"); } - else + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + InvalidateCredentials(); + } + if (!response.IsSuccessStatusCode) { var errorText = await response.Content.ReadAsStringAsync(); diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs index f73de84..1503e8c 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs @@ -539,12 +539,18 @@ protected async Task StreamingSyncIteration(Cancel { // Connection would be closed automatically right after this logger.LogDebug("Token expiring; reconnect"); + Options.Remote.InvalidateCredentials(); // For a rare case where the backend connector does not update the token // (uses the same one), this should have some delay. // await DelayRetry(); return new StreamingSyncIterationResult { Retry = true }; + } else if (remainingSeconds < 30) { + logger.LogDebug("Token will expire soon; reconnect"); + // Pre-emptively refresh the token + Options.Remote.InvalidateCredentials(); + return new StreamingSyncIterationResult { Retry = true }; } TriggerCrudUpload(); } From a31d2fc92d71df63dac52d3c98169c6e1754f21a Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 19 May 2025 15:23:35 +0200 Subject: [PATCH 4/8] Exposing upload and download errors in SyncStatus. --- .../Stream/StreamingSyncImplementation.cs | 57 ++++++++++++++++--- .../PowerSync.Common/DB/Crud/SyncStatus.cs | 16 +++++- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs index 1503e8c..d4cb85a 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs @@ -297,6 +297,10 @@ protected async Task StreamingSync(CancellationToken? signal, PowerSyncConnectio UpdateSyncStatus(new SyncStatusOptions { Connected = false, + DataFlow = new SyncDataFlowStatus + { + DownloadError = ex + } }); // On error, wait a little before retrying @@ -466,7 +470,13 @@ protected async Task StreamingSyncIteration(Cancel { Connected = true, LastSyncedAt = DateTime.Now, - DataFlow = new SyncDataFlowStatus { Downloading = false } + DataFlow = new SyncDataFlowStatus + { + Downloading = false + } + }, new UpdateSyncStatusOptions + { + ClearDownloadError = true }); } @@ -546,7 +556,9 @@ protected async Task StreamingSyncIteration(Cancel // await DelayRetry(); return new StreamingSyncIterationResult { Retry = true }; - } else if (remainingSeconds < 30) { + } + else if (remainingSeconds < 30) + { logger.LogDebug("Token will expire soon; reconnect"); // Pre-emptively refresh the token Options.Remote.InvalidateCredentials(); @@ -563,8 +575,13 @@ protected async Task StreamingSyncIteration(Cancel UpdateSyncStatus(new SyncStatusOptions { Connected = true, - LastSyncedAt = DateTime.Now - }); + LastSyncedAt = DateTime.Now, + }, + new UpdateSyncStatusOptions + { + ClearDownloadError = true + } + ); } else if (validatedCheckpoint == targetCheckpoint) { @@ -590,8 +607,12 @@ protected async Task StreamingSyncIteration(Cancel LastSyncedAt = DateTime.Now, DataFlow = new SyncDataFlowStatus { - Downloading = false + Downloading = false, } + }, + new UpdateSyncStatusOptions + { + ClearDownloadError = true }); } } @@ -661,6 +682,14 @@ await locks.ObtainLock(new LockOptions checkedCrudItem = nextCrudItem; await Options.UploadCrud(); + UpdateSyncStatus(new SyncStatusOptions + { + }, + new UpdateSyncStatusOptions + { + ClearUploadError = true + }); + } else { @@ -672,7 +701,14 @@ await locks.ObtainLock(new LockOptions catch (Exception ex) { checkedCrudItem = null; - UpdateSyncStatus(new SyncStatusOptions { DataFlow = new SyncDataFlowStatus { Uploading = false } }); + UpdateSyncStatus(new SyncStatusOptions + { + DataFlow = new SyncDataFlowStatus + { + Uploading = false, + UploadError = ex + } + }); await DelayRetry(); @@ -706,7 +742,10 @@ public async Task WaitForReady() await Task.CompletedTask; } - protected void UpdateSyncStatus(SyncStatusOptions options) + protected record UpdateSyncStatusOptions( + bool? ClearDownloadError = null, bool? ClearUploadError = null + ); + protected void UpdateSyncStatus(SyncStatusOptions options, UpdateSyncStatusOptions? updateOptions = null) { var updatedStatus = new SyncStatus(new SyncStatusOptions { @@ -716,7 +755,9 @@ protected void UpdateSyncStatus(SyncStatusOptions options) DataFlow = new SyncDataFlowStatus { Uploading = options.DataFlow?.Uploading ?? SyncStatus.DataFlowStatus.Uploading, - Downloading = options.DataFlow?.Downloading ?? SyncStatus.DataFlowStatus.Downloading + Downloading = options.DataFlow?.Downloading ?? SyncStatus.DataFlowStatus.Downloading, + DownloadError = updateOptions?.ClearDownloadError == true ? null : options.DataFlow?.DownloadError ?? SyncStatus.DataFlowStatus.DownloadError, + UploadError = updateOptions?.ClearUploadError == true ? null : options.DataFlow?.UploadError ?? SyncStatus.DataFlowStatus.UploadError, } }); diff --git a/PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs b/PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs index b04d1d9..070e3b1 100644 --- a/PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs +++ b/PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs @@ -9,6 +9,20 @@ public class SyncDataFlowStatus [JsonProperty("uploading")] public bool Uploading { get; set; } = false; + + /// + /// Error during downloading (including connecting). + /// Cleared on the next successful data download. + /// + [JsonProperty("downloadError")] + public Exception? DownloadError { get; set; } = null; + + /// + /// Error during uploading. + /// Cleared on the next successful upload. + /// + [JsonProperty("uploadError")] + public Exception? UploadError { get; set; } = null; } public class SyncStatusOptions @@ -73,7 +87,7 @@ public bool IsEqual(SyncStatus status) public string GetMessage() { var dataFlow = DataFlowStatus; - return $"SyncStatus"; + return $"SyncStatus"; } public string ToJSON() From bfb637ef31bfebd8e671e86d1d56fc75112c876d Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 19 May 2025 15:37:15 +0200 Subject: [PATCH 5/8] Changelog entry. --- PowerSync/PowerSync.Common/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index 58e0c7f..810de0b 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.0.2-alpha.2 + +- Updated core extension to v0.3.14 +- Loading last synced time from core extension +- Expose upload and download errors on SyncStatus +- Improved credentials management and error handling. Credentials are invalidated when they expire or become invalid based on responses from the PowerSync service. The frequency of credential fetching has been reduced as a result of this work. + ## 0.0.2-alpha.1 - Introduce package. Support for Desktop .NET use cases. From 2a7e42ab4f324879367a0520e58126ac4f7ac191 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 19 May 2025 16:09:35 +0200 Subject: [PATCH 6/8] Update PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs b/PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs index 070e3b1..c606f29 100644 --- a/PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs +++ b/PowerSync/PowerSync.Common/DB/Crud/SyncStatus.cs @@ -87,7 +87,7 @@ public bool IsEqual(SyncStatus status) public string GetMessage() { var dataFlow = DataFlowStatus; - return $"SyncStatus"; + return $"SyncStatus"; } public string ToJSON() From 56bb12ca0a3d8a4a4cfc206393bb2e2102ba5728 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Tue, 20 May 2025 16:56:55 +0200 Subject: [PATCH 7/8] Small comment. --- PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index e79388d..46f9bc2 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -241,7 +241,8 @@ protected async Task UpdateHasSynced() ); DateTime? lastCompleteSync = null; - + + // TODO: Will be altered/extended when reporting individual sync priority statuses are supported foreach (var result in results) { var parsedDate = DateTime.Parse(result.last_synced_at + "Z"); From 7cbfdb8c1ed706debb40cb19820e30e8c749993f Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Tue, 20 May 2025 16:58:24 +0200 Subject: [PATCH 8/8] Typo. --- PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index 46f9bc2..964e2af 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -242,7 +242,7 @@ protected async Task UpdateHasSynced() DateTime? lastCompleteSync = null; - // TODO: Will be altered/extended when reporting individual sync priority statuses are supported + // TODO: Will be altered/extended when reporting individual sync priority statuses is supported foreach (var result in results) { var parsedDate = DateTime.Parse(result.last_synced_at + "Z");