From 2a9d1a591b61329c75aeebaf589746dca4d3ff36 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 7 Jul 2026 14:33:08 +0200 Subject: [PATCH 01/18] Initial commit --- .../PowerSync.Common/PowerSync.Common.csproj | 27 +- .../PowerSync.Maui/PowerSync.Maui.csproj | 5 +- .../NodeClient.cs | 320 +++++++++--------- .../NodeConnector.cs | 226 ++++++------- .../PowerSync.Common.IntegrationTests.csproj | 4 +- .../SyncIntegrationTests.cs | 61 ++-- .../xunit.runner.json | 6 +- .../PowerSync.Common.PerformanceTests.csproj | 16 +- .../PowerSync.Common.Tests/DB/SchemaTests.cs | 14 +- .../PowerSync.Common.Tests/GlobalUsing.cs | 1 - .../PowerSync.Common.Tests.csproj | 19 +- .../Utils/FactAttribute.cs | 10 - Tools/Setup/Setup.csproj | 4 +- demos/CommandLine/CommandLine.csproj | 10 +- demos/MAUITodo/MAUITodo.csproj | 13 +- global.json | 4 +- 16 files changed, 372 insertions(+), 368 deletions(-) delete mode 100644 Tests/PowerSync/PowerSync.Common.Tests/GlobalUsing.cs delete mode 100644 Tests/PowerSync/PowerSync.Common.Tests/Utils/FactAttribute.cs diff --git a/PowerSync/PowerSync.Common/PowerSync.Common.csproj b/PowerSync/PowerSync.Common/PowerSync.Common.csproj index bc691115..e49d5ec4 100644 --- a/PowerSync/PowerSync.Common/PowerSync.Common.csproj +++ b/PowerSync/PowerSync.Common/PowerSync.Common.csproj @@ -1,7 +1,10 @@  - netstandard2.0;net6.0;net8.0;net9.0;net8.0-ios;net8.0-android;net8.0-maccatalyst;net9.0-ios;net9.0-android;net9.0-maccatalyst + net8.0;net9.0;net10.0;net9.0-android;net10.0-android + $(DefaultTargetFrameworks);net9.0-ios;net10.0-ios;net9.0-maccatalyst;net10.0-maccatalyst + $(DefaultTargetFrameworks) + 12 enable enable @@ -27,22 +30,26 @@ - - - - - - + + + + + - - + + - + + + + + + diff --git a/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj b/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj index 74e9a948..3b87666b 100644 --- a/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj +++ b/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj @@ -1,7 +1,10 @@  - netstandard2.0;net6.0;net8.0;net9.0;net8.0-ios;net8.0-android;net8.0-maccatalyst;net9.0-ios;net9.0-android;net9.0-maccatalyst + net9.0;net10.0;net9.0-android;net10.0-android + $(DefaultTargetFrameworks);net9.0-ios;net10.0-ios;net9.0-maccatalyst;net10.0-maccatalyst + $(DefaultTargetFrameworks) + 12 enable enable diff --git a/Tests/PowerSync/PowerSync.Common.IntegrationTests/NodeClient.cs b/Tests/PowerSync/PowerSync.Common.IntegrationTests/NodeClient.cs index dbd5073e..5ce805b7 100644 --- a/Tests/PowerSync/PowerSync.Common.IntegrationTests/NodeClient.cs +++ b/Tests/PowerSync/PowerSync.Common.IntegrationTests/NodeClient.cs @@ -1,161 +1,161 @@ -namespace PowerSync.Common.IntegrationTests; - -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; - -using PowerSync.Common.DB.Crud; - -public class NodeClient -{ - private readonly HttpClient _httpClient; - private readonly string _backendUrl; - private readonly string _userId; - - public NodeClient(string userId) - { - _httpClient = new HttpClient(); - _backendUrl = "http://localhost:6060"; - _userId = userId; - } - - public NodeClient(string backendUrl, string userId) - { - _httpClient = new HttpClient(); - _backendUrl = backendUrl; - _userId = userId; - } - - public Task CreateList(string id, string name) - { - return CreateItem("lists", id, name); - } - - async Task CreateItem(string table, string id, string name) - { - var data = new Dictionary - { - { "created_at", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") }, - { "name", name }, - { "owner_id", _userId } - }; - - var batch = new[] - { - new - { - op = UpdateType.PUT.ToString(), - table = table, - id = id, - data = data - } - }; - - var payload = JsonSerializer.Serialize(new { batch }); - var content = new StringContent(payload, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _httpClient.PostAsync($"{_backendUrl}/api/data", content); - - if (!response.IsSuccessStatusCode) - { - Console.WriteLine(await response.Content.ReadAsStringAsync()); - throw new Exception( - $"Failed to create item. Status: {response.StatusCode}, " + - $"Response: {await response.Content.ReadAsStringAsync()}" - ); - } - - return await response.Content.ReadAsStringAsync(); - } - - public Task DeleteList(string id) - { - return DeleteItem("lists", id); - } - - public Task CreateTodo(string id, string listId, string description) - { - return CreateTodoItem("todos", id, listId, description); - } - - async Task CreateTodoItem(string table, string id, string listId, string description) - { - var data = new Dictionary - { - { "created_at", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") }, - { "description", description }, - { "list_id", listId }, - { "created_by", _userId }, - { "completed", 0 }, - }; - - var batch = new[] - { - new - { - op = UpdateType.PUT.ToString(), - table = table, - id = id, - data = data - } - }; - - var payload = JsonSerializer.Serialize(new { batch }); - var content = new StringContent(payload, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _httpClient.PostAsync($"{_backendUrl}/api/data", content); - - if (!response.IsSuccessStatusCode) - { - Console.WriteLine(await response.Content.ReadAsStringAsync()); - throw new Exception( - $"Failed to create todo. Status: {response.StatusCode}, " + - $"Response: {await response.Content.ReadAsStringAsync()}" - ); - } - - return await response.Content.ReadAsStringAsync(); - } - - public Task DeleteTodo(string id) - { - return DeleteItem("todos", id); - } - - async Task DeleteItem(string table, string id) - { - var batch = new[] - { - new - { - op = UpdateType.DELETE.ToString(), - table = table, - id = id - } - }; - - var payload = JsonSerializer.Serialize(new { batch }); - var content = new StringContent(payload, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _httpClient.PostAsync($"{_backendUrl}/api/data", content); - - if (!response.IsSuccessStatusCode) - { - Console.WriteLine(await response.Content.ReadAsStringAsync()); - throw new Exception( - $"Failed to delete item. Status: {response.StatusCode}, " + - $"Response: {await response.Content.ReadAsStringAsync()}" - ); - } - - return await response.Content.ReadAsStringAsync(); - } - - public void Dispose() - { - _httpClient?.Dispose(); - } +namespace PowerSync.Common.IntegrationTests; + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +using PowerSync.Common.DB.Crud; + +public class NodeClient +{ + private readonly HttpClient _httpClient; + private readonly string _backendUrl; + private readonly string _userId; + + public NodeClient(string userId) + { + _httpClient = new HttpClient(); + _backendUrl = "http://localhost:6060"; + _userId = userId; + } + + public NodeClient(string backendUrl, string userId) + { + _httpClient = new HttpClient(); + _backendUrl = backendUrl; + _userId = userId; + } + + public Task CreateList(string id, string name) + { + return CreateItem("lists", id, name); + } + + async Task CreateItem(string table, string id, string name) + { + var data = new Dictionary + { + { "created_at", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") }, + { "name", name }, + { "owner_id", _userId } + }; + + var batch = new[] + { + new + { + op = UpdateType.PUT.ToString(), + table = table, + id = id, + data = data + } + }; + + var payload = JsonSerializer.Serialize(new { batch }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient.PostAsync($"{_backendUrl}/api/data", content); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine(await response.Content.ReadAsStringAsync()); + throw new Exception( + $"Failed to create item. Status: {response.StatusCode}, " + + $"Response: {await response.Content.ReadAsStringAsync()}" + ); + } + + return await response.Content.ReadAsStringAsync(); + } + + public Task DeleteList(string id) + { + return DeleteItem("lists", id); + } + + public Task CreateTodo(string id, string listId, string description) + { + return CreateTodoItem("todos", id, listId, description); + } + + async Task CreateTodoItem(string table, string id, string listId, string description) + { + var data = new Dictionary + { + { "created_at", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") }, + { "description", description }, + { "list_id", listId }, + { "created_by", _userId }, + { "completed", 0 }, + }; + + var batch = new[] + { + new + { + op = UpdateType.PUT.ToString(), + table = table, + id = id, + data = data + } + }; + + var payload = JsonSerializer.Serialize(new { batch }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient.PostAsync($"{_backendUrl}/api/data", content); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine(await response.Content.ReadAsStringAsync()); + throw new Exception( + $"Failed to create todo. Status: {response.StatusCode}, " + + $"Response: {await response.Content.ReadAsStringAsync()}" + ); + } + + return await response.Content.ReadAsStringAsync(); + } + + public Task DeleteTodo(string id) + { + return DeleteItem("todos", id); + } + + async Task DeleteItem(string table, string id) + { + var batch = new[] + { + new + { + op = UpdateType.DELETE.ToString(), + table = table, + id = id + } + }; + + var payload = JsonSerializer.Serialize(new { batch }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient.PostAsync($"{_backendUrl}/api/data", content); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine(await response.Content.ReadAsStringAsync()); + throw new Exception( + $"Failed to delete item. Status: {response.StatusCode}, " + + $"Response: {await response.Content.ReadAsStringAsync()}" + ); + } + + return await response.Content.ReadAsStringAsync(); + } + + public void Dispose() + { + _httpClient?.Dispose(); + } } diff --git a/Tests/PowerSync/PowerSync.Common.IntegrationTests/NodeConnector.cs b/Tests/PowerSync/PowerSync.Common.IntegrationTests/NodeConnector.cs index ae2a1b2b..6585a42b 100644 --- a/Tests/PowerSync/PowerSync.Common.IntegrationTests/NodeConnector.cs +++ b/Tests/PowerSync/PowerSync.Common.IntegrationTests/NodeConnector.cs @@ -1,114 +1,114 @@ -namespace PowerSync.Common.IntegrationTests; - - -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; - -using PowerSync.Common.Client; -using PowerSync.Common.Client.Connection; -using PowerSync.Common.DB.Crud; - - -public class NodeConnector : IPowerSyncBackendConnector -{ - private readonly HttpClient _httpClient; - - public string BackendUrl { get; } - public string PowerSyncUrl { get; } - public string UserId { get; private set; } - private string? clientId; - - public NodeConnector(string userId) - { - _httpClient = new HttpClient(); - - // Load or generate User ID - UserId = userId; - - BackendUrl = "http://localhost:6060"; - PowerSyncUrl = "http://localhost:8080"; - - clientId = null; - } - - public async Task FetchCredentials() - { - string tokenEndpoint = "api/auth/token"; - string url = $"{BackendUrl}/{tokenEndpoint}?user_id={UserId}"; - - HttpResponseMessage response = await _httpClient.GetAsync(url); - if (!response.IsSuccessStatusCode) - { - throw new Exception($"Received {response.StatusCode} from {tokenEndpoint}: {await response.Content.ReadAsStringAsync()}"); - } - - string responseBody = await response.Content.ReadAsStringAsync(); - var jsonResponse = JsonSerializer.Deserialize>(responseBody); - - if (jsonResponse == null || !jsonResponse.ContainsKey("token")) - { - throw new Exception("Invalid response received from authentication endpoint."); - } - - return new PowerSyncCredentials(PowerSyncUrl, jsonResponse["token"]); - } - - public async Task UploadData(IPowerSyncDatabase database) - { - CrudTransaction? transaction; - try - { - transaction = await database.GetNextCrudTransaction(); - } - catch (Exception ex) - { - Console.WriteLine($"UploadData Error: {ex.Message}"); - return; - } - - if (transaction == null) - { - return; - } - - clientId ??= await database.GetClientId(); - - try - { - var batch = new List(); - - foreach (var operation in transaction.Crud) - { - batch.Add(new - { - op = operation.Op.ToString(), - table = operation.Table, - id = operation.Id, - data = operation.OpData - }); - } - - var payload = JsonSerializer.Serialize(new { batch }); - var content = new StringContent(payload, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _httpClient.PostAsync($"{BackendUrl}/api/data", content); - - if (!response.IsSuccessStatusCode) - { - throw new Exception($"Received {response.StatusCode} from /api/data: {await response.Content.ReadAsStringAsync()}"); - } - - await transaction.Complete(); - } - catch (Exception ex) - { - Console.WriteLine($"UploadData Error: {ex.Message}"); - throw; - } - } +namespace PowerSync.Common.IntegrationTests; + + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +using PowerSync.Common.Client; +using PowerSync.Common.Client.Connection; +using PowerSync.Common.DB.Crud; + + +public class NodeConnector : IPowerSyncBackendConnector +{ + private readonly HttpClient _httpClient; + + public string BackendUrl { get; } + public string PowerSyncUrl { get; } + public string UserId { get; private set; } + private string? clientId; + + public NodeConnector(string userId) + { + _httpClient = new HttpClient(); + + // Load or generate User ID + UserId = userId; + + BackendUrl = "http://localhost:6060"; + PowerSyncUrl = "http://localhost:8080"; + + clientId = null; + } + + public async Task FetchCredentials() + { + string tokenEndpoint = "api/auth/token"; + string url = $"{BackendUrl}/{tokenEndpoint}?user_id={UserId}"; + + HttpResponseMessage response = await _httpClient.GetAsync(url); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Received {response.StatusCode} from {tokenEndpoint}: {await response.Content.ReadAsStringAsync()}"); + } + + string responseBody = await response.Content.ReadAsStringAsync(); + var jsonResponse = JsonSerializer.Deserialize>(responseBody); + + if (jsonResponse == null || !jsonResponse.ContainsKey("token")) + { + throw new Exception("Invalid response received from authentication endpoint."); + } + + return new PowerSyncCredentials(PowerSyncUrl, jsonResponse["token"]); + } + + public async Task UploadData(IPowerSyncDatabase database) + { + CrudTransaction? transaction; + try + { + transaction = await database.GetNextCrudTransaction(); + } + catch (Exception ex) + { + Console.WriteLine($"UploadData Error: {ex.Message}"); + return; + } + + if (transaction == null) + { + return; + } + + clientId ??= await database.GetClientId(); + + try + { + var batch = new List(); + + foreach (var operation in transaction.Crud) + { + batch.Add(new + { + op = operation.Op.ToString(), + table = operation.Table, + id = operation.Id, + data = operation.OpData + }); + } + + var payload = JsonSerializer.Serialize(new { batch }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient.PostAsync($"{BackendUrl}/api/data", content); + + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Received {response.StatusCode} from /api/data: {await response.Content.ReadAsStringAsync()}"); + } + + await transaction.Complete(); + } + catch (Exception ex) + { + Console.WriteLine($"UploadData Error: {ex.Message}"); + throw; + } + } } diff --git a/Tests/PowerSync/PowerSync.Common.IntegrationTests/PowerSync.Common.IntegrationTests.csproj b/Tests/PowerSync/PowerSync.Common.IntegrationTests/PowerSync.Common.IntegrationTests.csproj index 3d029a9c..eefd14e9 100644 --- a/Tests/PowerSync/PowerSync.Common.IntegrationTests/PowerSync.Common.IntegrationTests.csproj +++ b/Tests/PowerSync/PowerSync.Common.IntegrationTests/PowerSync.Common.IntegrationTests.csproj @@ -1,7 +1,7 @@ - + - net8.0 + net8.0;net9.0;net10.0 12 enable enable diff --git a/Tests/PowerSync/PowerSync.Common.IntegrationTests/SyncIntegrationTests.cs b/Tests/PowerSync/PowerSync.Common.IntegrationTests/SyncIntegrationTests.cs index 699d9e41..4ee7c4d8 100644 --- a/Tests/PowerSync/PowerSync.Common.IntegrationTests/SyncIntegrationTests.cs +++ b/Tests/PowerSync/PowerSync.Common.IntegrationTests/SyncIntegrationTests.cs @@ -9,9 +9,9 @@ namespace PowerSync.Common.IntegrationTests; [Trait("Category", "Integration")] public class SyncIntegrationTests : IAsyncLifetime { - private record ListResult(string id, string name, string owner_id, string created_at); + private record ListResult(string id, string created_at, string name, string owner_id); - private record TodoResult(string id, string list_id, string content, string owner_id, string created_at); + private record TodoResult(string id, string list_id, string created_at, string completed_at, string description, string created_by, string completed_by, int completed); private readonly string userId = Uuid(); @@ -33,12 +33,13 @@ public async Task InitializeAsync() nodeClient = new NodeClient(userId); db = new PowerSyncDatabase(new PowerSyncDatabaseOptions { - Database = new SQLOpenOptions { DbFilename = "powersync-sync-tests.db" }, + Database = new SQLOpenOptions { DbFilename = $"powersync-sync-tests-{userId}.db" }, Schema = TestSchema.PowerSyncSchema, Logger = logger }); await db.Init(); + await db.DisconnectAndClear(); var connector = new NodeConnector(userId); Console.WriteLine($"Using User ID: {userId}"); @@ -52,7 +53,6 @@ public async Task InitializeAsync() { "environment", "integration-tests" } } }); - await db.Connect(connector); await db.WaitForFirstSync(); } catch (Exception ex) @@ -68,6 +68,7 @@ public async Task DisposeAsync() await Task.Delay(2000); await db.DisconnectAndClear(); await db.Close(); + try { File.Delete($"powersync-sync-tests-{userId}.db"); } catch { } } [IntegrationFact(Timeout = 3000)] @@ -93,48 +94,39 @@ public async Task SyncDownCreateOperationTest() await watched.Task; } - [IntegrationFact(Timeout = 3000)] + [IntegrationFact(Timeout = 6000)] public async Task SyncDownDeleteOperationTest() { - var watched = new TaskCompletionSource(); + var created = new TaskCompletionSource(); + var deleted = new TaskCompletionSource(); var cts = new CancellationTokenSource(); var id = Uuid(); - await nodeClient.CreateList(id, name: "Test List to delete"); - + // Use a single Watch for the full create+delete lifecycle so the listener + // channel is registered before any events can be missed. _ = Task.Run(async () => { + bool sawCreate = false; await foreach (var x in db.Watch("select * from lists where id = ?", [id], new() { Signal = cts.Token })) { - // Verify that the item was added locally - if (x.Length == 1) + if (!sawCreate && x.Length == 1) { - watched.SetResult(true); - cts.Cancel(); + sawCreate = true; + created.SetResult(true); } - } - }); - - await watched.Task; - await nodeClient.DeleteList(id); - - watched = new TaskCompletionSource(); - cts = new CancellationTokenSource(); - - _ = Task.Run(async () => - { - await foreach (var x in db.Watch("select * from lists where id = ?", [id], new() { Signal = cts.Token })) - { - // Verify that the item was deleted locally - if (x.Length == 0) + else if (sawCreate && x.Length == 0) { - watched.SetResult(true); + deleted.SetResult(true); cts.Cancel(); } } }); - await watched.Task; + await nodeClient.CreateList(id, name: "Test List to delete"); + await created.Task; + + await nodeClient.DeleteList(id); + await deleted.Task; } [IntegrationFact(Timeout = 5000)] @@ -147,7 +139,7 @@ public async Task SyncDownLargeCreateOperationTest() _ = Task.Run(async () => { - await foreach (var x in db.Watch("select * from lists where id = ?", [id], new() { Signal = cts.Token })) + await foreach (var x in db.Watch("select * from lists where name = ?", [listName], new() { Signal = cts.Token })) { // Verify that the item was added locally if (x.Length == 100) @@ -176,7 +168,7 @@ public async Task SyncDownCreateOperationAfterLargeUploadTest() _ = Task.Run(async () => { - await foreach (var x in db.Watch("select * from lists where id = ?", [id], new() { Signal = cts.Token })) + await foreach (var x in db.Watch("select * from lists where name = ?", [listName], new() { Signal = cts.Token })) { // Verify that the items were added locally if (x.Length == 100) @@ -199,8 +191,11 @@ await db.Execute("insert into lists (id, name, owner_id, created_at) values (uui } await localInsertWatch.Task; - // let the crud upload finish - await Task.Delay(2000); + // Wait for CRUD upload to complete before creating backend item + while (db.CurrentStatus.DataFlowStatus.Uploading) + { + await Task.Delay(50); + } await nodeClient.CreateList(Uuid(), listName); await backendInsertWatch.Task; diff --git a/Tests/PowerSync/PowerSync.Common.IntegrationTests/xunit.runner.json b/Tests/PowerSync/PowerSync.Common.IntegrationTests/xunit.runner.json index fcdb064a..27dcc91d 100644 --- a/Tests/PowerSync/PowerSync.Common.IntegrationTests/xunit.runner.json +++ b/Tests/PowerSync/PowerSync.Common.IntegrationTests/xunit.runner.json @@ -1,5 +1,7 @@ { "methodDisplay": "method", "diagnosticMessages": true, - "longRunningTestSeconds": 10 -} \ No newline at end of file + "longRunningTestSeconds": 10, + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +} diff --git a/Tests/PowerSync/PowerSync.Common.PerformanceTests/PowerSync.Common.PerformanceTests.csproj b/Tests/PowerSync/PowerSync.Common.PerformanceTests/PowerSync.Common.PerformanceTests.csproj index 25d9697b..7773caa6 100644 --- a/Tests/PowerSync/PowerSync.Common.PerformanceTests/PowerSync.Common.PerformanceTests.csproj +++ b/Tests/PowerSync/PowerSync.Common.PerformanceTests/PowerSync.Common.PerformanceTests.csproj @@ -1,7 +1,7 @@  - net6.0;net8.0;net9.0 + net8.0;net9.0;net10.0 12 enable enable @@ -11,10 +11,16 @@ - - - - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 20c6e290..6f0ebf71 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -201,7 +201,7 @@ public void AttributeParser_Logs_Test() class Invalid1 { public string id { get; set; } = ""; } [Fact] - public async void AttributeParser_InvalidSchema_1() + public async Task AttributeParser_InvalidSchema_1() { var ex = await Assert.ThrowsAsync(() => { @@ -214,7 +214,7 @@ public async void AttributeParser_InvalidSchema_1() [Table("invalid")] class Invalid2 { } [Fact] - public async void AttributeParser_InvalidSchema_2() + public async Task AttributeParser_InvalidSchema_2() { var ex = await Assert.ThrowsAsync(() => { @@ -227,7 +227,7 @@ public async void AttributeParser_InvalidSchema_2() [Table("invalid")] class Invalid3 { public int id { get; set; } } [Fact] - public async void AttributeParser_InvalidSchema_3() + public async Task AttributeParser_InvalidSchema_3() { var ex = await Assert.ThrowsAsync(() => { @@ -244,7 +244,7 @@ class Invalid4 public string id { get; set; } = ""; } [Fact] - public async void AttributeParser_InvalidSchema_4() + public async Task AttributeParser_InvalidSchema_4() { var ex = await Assert.ThrowsAsync(() => { @@ -261,7 +261,7 @@ class Invalid5 public Invalid1 invalid_type { get; set; } = default!; } [Fact] - public async void AttributeParser_InvalidSchema_5() + public async Task AttributeParser_InvalidSchema_5() { var ex = await Assert.ThrowsAsync(() => { @@ -277,7 +277,7 @@ class Invalid6 public string id { get; set; } = ""; } [Fact] - public async void AttributeParser_InvalidSchema_6() + public async Task AttributeParser_InvalidSchema_6() { var ex = await Assert.ThrowsAsync(() => { @@ -293,7 +293,7 @@ class Invalid7 public string id { get; set; } = ""; } [Fact] - public async void AttributeParser_InvalidSchema_7() + public async Task AttributeParser_InvalidSchema_7() { var ex = await Assert.ThrowsAsync(() => { diff --git a/Tests/PowerSync/PowerSync.Common.Tests/GlobalUsing.cs b/Tests/PowerSync/PowerSync.Common.Tests/GlobalUsing.cs deleted file mode 100644 index cca77378..00000000 --- a/Tests/PowerSync/PowerSync.Common.Tests/GlobalUsing.cs +++ /dev/null @@ -1 +0,0 @@ -global using FactAttribute = PowerSync.Common.Tests.Utils.FactAttribute; diff --git a/Tests/PowerSync/PowerSync.Common.Tests/PowerSync.Common.Tests.csproj b/Tests/PowerSync/PowerSync.Common.Tests/PowerSync.Common.Tests.csproj index a153f06b..c381b6af 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/PowerSync.Common.Tests.csproj +++ b/Tests/PowerSync/PowerSync.Common.Tests/PowerSync.Common.Tests.csproj @@ -1,7 +1,7 @@ - net6.0;net8.0;net9.0 + net8.0;net9.0;net10.0 12 enable enable @@ -11,14 +11,15 @@ - - - - - - - - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Utils/FactAttribute.cs b/Tests/PowerSync/PowerSync.Common.Tests/Utils/FactAttribute.cs deleted file mode 100644 index 53c67cfd..00000000 --- a/Tests/PowerSync/PowerSync.Common.Tests/Utils/FactAttribute.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace PowerSync.Common.Tests.Utils; - -[AttributeUsage(AttributeTargets.Method)] -public class FactAttribute : Xunit.FactAttribute -{ - public FactAttribute() - { - Timeout = 5000; - } -} diff --git a/Tools/Setup/Setup.csproj b/Tools/Setup/Setup.csproj index 83831b3f..c5c3bf8e 100644 --- a/Tools/Setup/Setup.csproj +++ b/Tools/Setup/Setup.csproj @@ -2,6 +2,6 @@ Exe - net8.0 + net10.0 - \ No newline at end of file + diff --git a/demos/CommandLine/CommandLine.csproj b/demos/CommandLine/CommandLine.csproj index c4bc4de6..7cf868b5 100644 --- a/demos/CommandLine/CommandLine.csproj +++ b/demos/CommandLine/CommandLine.csproj @@ -3,7 +3,7 @@ Exe 0.0.1 - net8.0;net9.0 + net8.0;net9.0;net10.0 12 enable enable @@ -12,13 +12,13 @@ - - - + + + - + diff --git a/demos/MAUITodo/MAUITodo.csproj b/demos/MAUITodo/MAUITodo.csproj index 56d224b4..187d6937 100644 --- a/demos/MAUITodo/MAUITodo.csproj +++ b/demos/MAUITodo/MAUITodo.csproj @@ -3,9 +3,10 @@ com.companyname.todo - net8.0-android;net9.0-android - $(TargetFrameworks);net8.0-windows10.0.19041.0 - $(TargetFrameworks);net8.0-ios;net8.0-maccatalyst;net9.0-ios;net9.0-maccatalyst + net9.0-android;net10.0-android + $(DefaultTargetFrameworks) + $(DefaultTargetFrameworks);net9.0-windows;net10.0-windows + $(DefaultTargetFrameworks);net9.0-ios;net9.0-maccatalyst;net10.0-ios;net10.0-maccatalyst Exe MAUITodo @@ -29,7 +30,7 @@ true true - 8.0.90 + 8.0.90 15.0 15.0 @@ -71,7 +72,7 @@ - - + + diff --git a/global.json b/global.json index a2cd87e0..3ac0b1d9 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "9.0.200" + "version": "10.0.109" } - } \ No newline at end of file + } From 353c6e376352483e1ffeaac98652eb6f0f6dbb34 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 14 Jul 2026 16:52:07 +0200 Subject: [PATCH 02/18] Use .net 10 for workflows --- .github/workflows/dev-packages.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-packages.yml b/.github/workflows/dev-packages.yml index c275e77a..af2e3bdf 100644 --- a/.github/workflows/dev-packages.yml +++ b/.github/workflows/dev-packages.yml @@ -18,7 +18,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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f617457..429b3527 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 15bc561c..788ea5a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -30,4 +30,4 @@ jobs: run: dotnet restore - name: Run tests - run: dotnet test -v n --framework net8.0 \ No newline at end of file + run: dotnet test -v n --framework net8.0 From 6383afefe04ef5b0e54c34d45d7a66d95761ac9f Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 15 Jul 2026 10:59:40 +0200 Subject: [PATCH 03/18] Update global.json feature band --- global.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/global.json b/global.json index 3ac0b1d9..c0c25fe2 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,7 @@ { "sdk": { - "version": "10.0.109" + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false } } From 0f4beaadfdfc93de406b9e47f6d994231c492d2d Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 15 Jul 2026 11:03:05 +0200 Subject: [PATCH 04/18] Replace net8.0 with net10.0 in READMEs --- README.md | 12 ++++++------ demos/MAUITodo/README.md | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f71ef329..1eb878ef 100644 --- a/README.md +++ b/README.md @@ -108,13 +108,13 @@ dotnet restore Run all tests ```bash -dotnet test -v n --framework net8.0 +dotnet test -v n --framework net10.0 ``` Run a specific test ```bash -dotnet test -v n --framework net8.0 --filter "test-file-pattern" +dotnet test -v n --framework net10.0 --filter "test-file-pattern" ``` ### Integration Tests @@ -122,26 +122,26 @@ Integration tests in `PowerSync.Common.IntegrationTests` are intended to run aga The integration tests are disabled by default, define the following environment variable when running the tests. ```bash -RUN_INTEGRATION_TESTS=true dotnet test -v n --framework net8.0 +RUN_INTEGRATION_TESTS=true dotnet test -v n --framework net10.0 ``` Only run integration tests, without any unit tests. ```bash -RUN_INTEGRATION_TESTS=true dotnet test -v n --framework net8.0 --filter "Category=Integration" +RUN_INTEGRATION_TESTS=true dotnet test -v n --framework net10.0 --filter "Category=Integration" ``` ### Performance Tests Performance tests in `PowerSync.Common.PerformanceTests` are disabled by default, define the following environment variable when running the tests. ```bash -RUN_PERFORMANCE_TESTS=true dotnet test -v n --framework net8.0 +RUN_PERFORMANCE_TESTS=true dotnet test -v n --framework net10.0 ``` Only run performance tests, without any unit tests. ```bash -RUN_PERFORMANCE_TESTS=true dotnet test -v n --framework net8.0 --filter "Category=Performance" +RUN_PERFORMANCE_TESTS=true dotnet test -v n --framework net10.0 --filter "Category=Performance" ``` ## Using the PowerSync.Common package in your project diff --git a/demos/MAUITodo/README.md b/demos/MAUITodo/README.md index ca474d8b..de2762b9 100644 --- a/demos/MAUITodo/README.md +++ b/demos/MAUITodo/README.md @@ -27,29 +27,29 @@ dotnet restore ### iOS ```sh -dotnet build -t:Run -f:net8.0-ios +dotnet build -t:Run -f:net10.0-ios ``` Specifyng an iOS simulator ```sh -dotnet build -t:Run -f:net8.0-ios -p:_DeviceName=:v2:udid=B1CA156A-56FC-4C3C-B35D-4BC349111FDF +dotnet build -t:Run -f:net10.0-ios -p:_DeviceName=:v2:udid=B1CA156A-56FC-4C3C-B35D-4BC349111FDF ``` ### Android ```sh -dotnet build -t:Run -f:net8.0-android +dotnet build -t:Run -f:net10.0-android ``` Specifying an Android emulator ```sh -dotnet build -t:Run -f:net8.0-android -p:_DeviceName=emulator-5554 +dotnet build -t:Run -f:net10.0-android -p:_DeviceName=emulator-5554 ``` ### MacCatalyst ```sh -dotnet build -t:Run -f:net8.0-maccatalyst +dotnet build -t:Run -f:net10.0-maccatalyst ``` From aebb84909a50153d57d6f1b534d152164932c967 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 15 Jul 2026 11:04:01 +0200 Subject: [PATCH 05/18] Replace net8.0 with net10.0 in test comments --- .../PowerSync.Common.Tests/Attachments/AttachmentTests.cs | 2 +- .../PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs | 2 +- Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CRUDTests.cs | 2 +- .../Client/Sync/StreamingSyncRetryTests.cs | 2 +- .../PowerSync.Common.Tests/Client/Sync/SyncStreamsTests.cs | 2 +- Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs | 2 +- Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs | 2 +- .../PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Attachments/AttachmentTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Attachments/AttachmentTests.cs index af883348..9dfc49d3 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Attachments/AttachmentTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Attachments/AttachmentTests.cs @@ -8,7 +8,7 @@ namespace PowerSync.Common.Tests.Attachments; using PowerSync.Common.Tests.Utils; /// -/// dotnet test -v n --framework net8.0 --filter "AttachmentTests" +/// dotnet test -v n --framework net10.0 --filter "AttachmentTests" /// [Collection("AttachmentTests")] public class AttachmentTests : IAsyncLifetime diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index 6af182e8..9283bc5b 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -9,7 +9,7 @@ namespace PowerSync.Common.Tests.Client; using PowerSync.Common.Tests.Utils; /// -/// dotnet test -v n --framework net8.0 --filter "PowerSyncDatabaseTests" +/// dotnet test -v n --framework net10.0 --filter "PowerSyncDatabaseTests" /// [Collection("PowerSyncDatabaseTests")] public class PowerSyncDatabaseTests : IAsyncLifetime diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CRUDTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CRUDTests.cs index 58525fb5..094a0921 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CRUDTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CRUDTests.cs @@ -11,7 +11,7 @@ namespace PowerSync.Common.Tests.Client.Sync; /// -/// dotnet test -v n --framework net8.0 --filter "CRUDTests" +/// dotnet test -v n --framework net10.0 --filter "CRUDTests" /// public class CRUDTests : IAsyncLifetime { diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs index 659af874..ffd4b747 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs @@ -9,7 +9,7 @@ namespace PowerSync.Common.Tests.Client.Sync; using PowerSync.Common.Tests.Utils.Sync; /// -/// dotnet test -v n --framework net8.0 --filter "StreamingSyncRetryTests" +/// dotnet test -v n --framework net10.0 --filter "StreamingSyncRetryTests" /// public class StreamingSyncRetryTests { diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncStreamsTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncStreamsTests.cs index e646b07e..96225815 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncStreamsTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncStreamsTests.cs @@ -7,7 +7,7 @@ namespace PowerSync.Common.Tests.Client.Sync; using PowerSync.Common.Tests.Utils.Sync; /// -/// dotnet test -v n --framework net8.0 --filter "SyncStreamsTests" +/// dotnet test -v n --framework net10.0 --filter "SyncStreamsTests" /// public class SyncStreamsTests : IAsyncLifetime { diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs index a10f3b4a..09c5effa 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs @@ -6,7 +6,7 @@ namespace PowerSync.Common.Tests.Client.Sync; /// -/// dotnet test -v n --framework net8.0 --filter "SyncTests" +/// dotnet test -v n --framework net10.0 --filter "SyncTests" /// public class SyncTests : IAsyncLifetime { diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 6f0ebf71..29aea943 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -7,7 +7,7 @@ namespace PowerSync.Common.Tests.DB.Schema; using PowerSync.Common.Tests; /// -/// dotnet test -v n --framework net8.0 --filter "SchemaTests" +/// dotnet test -v n --framework net10.0 --filter "SchemaTests" /// public class SchemaTests { diff --git a/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs index 8789b7b9..5efe8a7f 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs @@ -8,7 +8,7 @@ namespace PowerSync.Common.Tests.MDSQLite; using PowerSync.Common.Utils; /// -/// dotnet test -v n --framework net8.0 --filter "MDSQLiteAdapterTests" +/// dotnet test -v n --framework net10.0 --filter "MDSQLiteAdapterTests" /// [Collection("MDSQLiteAdapterTests")] public class MDSQLiteAdapterTests From ba732e327c09c1121ac51a830510a772c88d56d6 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 15 Jul 2026 11:57:38 +0200 Subject: [PATCH 06/18] Remove unneeded csproj config/deps --- demos/MAUITodo/MAUITodo.csproj | 8 -------- 1 file changed, 8 deletions(-) diff --git a/demos/MAUITodo/MAUITodo.csproj b/demos/MAUITodo/MAUITodo.csproj index 187d6937..6d03d328 100644 --- a/demos/MAUITodo/MAUITodo.csproj +++ b/demos/MAUITodo/MAUITodo.csproj @@ -39,12 +39,6 @@ 10.0.19041.0 - - - iossimulator-x64 - true - - @@ -64,8 +58,6 @@ - - From e513c85923c36f6d99c1068d272f561a9f853873 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 15 Jul 2026 12:20:38 +0200 Subject: [PATCH 07/18] Run net10.0 instead of net8.0 for test --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 788ea5a9..110b65f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 From 4e2ab77a3f2b5493d21a68069b3e256de02730ad Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 17 Aug 2026 16:25:58 +0200 Subject: [PATCH 08/18] remove fluff + unused deps, remove SQLitePCLRaw workaround --- Directory.build.props | 5 +---- IsExternalInit.cs | 7 ------- .../PowerSync.Common/PowerSync.Common.csproj | 21 +++++++------------ .../PowerSync.Maui/PowerSync.Maui.csproj | 7 +++---- .../Client/PowerSyncDatabaseTests.cs | 2 +- .../PowerSync.Common.Tests.csproj | 1 - demos/CommandLine/CommandLine.csproj | 1 - demos/MAUITodo/MAUITodo.csproj | 7 +++---- demos/WPF/WPF.csproj | 2 +- 9 files changed, 16 insertions(+), 37 deletions(-) delete mode 100644 IsExternalInit.cs diff --git a/Directory.build.props b/Directory.build.props index fe14bb0e..0a2340a9 100644 --- a/Directory.build.props +++ b/Directory.build.props @@ -2,9 +2,6 @@ $(MSBuildWarningsAsMessages);NETSDK1202 - true + NU1500 - - - diff --git a/IsExternalInit.cs b/IsExternalInit.cs deleted file mode 100644 index 7d866a84..00000000 --- a/IsExternalInit.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.ComponentModel; - -namespace System.Runtime.CompilerServices -{ - [EditorBrowsable(EditorBrowsableState.Never)] - internal class IsExternalInit { } -} diff --git a/PowerSync/PowerSync.Common/PowerSync.Common.csproj b/PowerSync/PowerSync.Common/PowerSync.Common.csproj index e49d5ec4..7310aa85 100644 --- a/PowerSync/PowerSync.Common/PowerSync.Common.csproj +++ b/PowerSync/PowerSync.Common/PowerSync.Common.csproj @@ -1,9 +1,8 @@  - net8.0;net9.0;net10.0;net9.0-android;net10.0-android - $(DefaultTargetFrameworks);net9.0-ios;net10.0-ios;net9.0-maccatalyst;net10.0-maccatalyst - $(DefaultTargetFrameworks) + net8.0;net9.0;net10.0;net9.0-android;net10.0-android + $(TargetFrameworks);net9.0-ios;net10.0-ios;net9.0-maccatalyst;net10.0-maccatalyst 12 enable @@ -20,7 +19,8 @@ https://github.com/powersync-ja/powersync-dotnet/PowerSync/PowerSync.Common/CHANGELOG.md powersync local-first local-storage state-management offline sql db persistence sqlite sync icon.png - NU5100 + NU5100;1591 + true README.md $(DefaultItemExcludes);runtimes/**/*.*; @@ -31,13 +31,11 @@ - - - + + + - - @@ -45,11 +43,6 @@ - - - - - diff --git a/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj b/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj index 3b87666b..751532b2 100644 --- a/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj +++ b/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj @@ -1,9 +1,8 @@  - net9.0;net10.0;net9.0-android;net10.0-android - $(DefaultTargetFrameworks);net9.0-ios;net10.0-ios;net9.0-maccatalyst;net10.0-maccatalyst - $(DefaultTargetFrameworks) + net9.0;net10.0;net9.0-android;net10.0-android + $(TargetFrameworks);net9.0-ios;net10.0-ios;net9.0-maccatalyst;net10.0-maccatalyst 12 enable @@ -20,7 +19,7 @@ https://github.com/powersync-ja/powersync-dotnet/PowerSync/PowerSync.Maui/CHANGELOG.md powersync local-first local-storage state-management offline sql db persistence sqlite sync icon.png - NU5100 + NU5100;1591 README.md true true diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index 29d40210..56cebe4c 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -1096,7 +1096,7 @@ await db.UpdateSchema(new Schema( Assert.True(await sem.WaitAsync(500)); Assert.Single(events); Assert.True(events.TryDequeue(out var change)); - Assert.Equal(["assets"], change.ChangedTables); + Assert.Equal(["ps_data__assets"], change.ChangedTables); } [Fact] diff --git a/Tests/PowerSync/PowerSync.Common.Tests/PowerSync.Common.Tests.csproj b/Tests/PowerSync/PowerSync.Common.Tests/PowerSync.Common.Tests.csproj index c381b6af..08581b5a 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/PowerSync.Common.Tests.csproj +++ b/Tests/PowerSync/PowerSync.Common.Tests/PowerSync.Common.Tests.csproj @@ -17,7 +17,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/demos/CommandLine/CommandLine.csproj b/demos/CommandLine/CommandLine.csproj index 7cf868b5..36ac2f4e 100644 --- a/demos/CommandLine/CommandLine.csproj +++ b/demos/CommandLine/CommandLine.csproj @@ -13,7 +13,6 @@ - diff --git a/demos/MAUITodo/MAUITodo.csproj b/demos/MAUITodo/MAUITodo.csproj index 6d03d328..9f38150b 100644 --- a/demos/MAUITodo/MAUITodo.csproj +++ b/demos/MAUITodo/MAUITodo.csproj @@ -3,10 +3,9 @@ com.companyname.todo - net9.0-android;net10.0-android - $(DefaultTargetFrameworks) - $(DefaultTargetFrameworks);net9.0-windows;net10.0-windows - $(DefaultTargetFrameworks);net9.0-ios;net9.0-maccatalyst;net10.0-ios;net10.0-maccatalyst + net9.0-android;net10.0-android + $(TargetFrameworks);net9.0-windows;net10.0-windows + $(TargetFrameworks);net9.0-ios;net9.0-maccatalyst;net10.0-ios;net10.0-maccatalyst Exe MAUITodo diff --git a/demos/WPF/WPF.csproj b/demos/WPF/WPF.csproj index 89e7de23..c69cd826 100644 --- a/demos/WPF/WPF.csproj +++ b/demos/WPF/WPF.csproj @@ -2,7 +2,7 @@ WinExe - net9.0-windows + net10.0-windows PowersyncDotnetTodoList enable enable From 87868397a01892138ee0df979e1b30852c8b4097 Mon Sep 17 00:00:00 2001 From: Luc de Cafmeyer Date: Tue, 22 Sep 2026 11:45:58 +0200 Subject: [PATCH 09/18] Deprecate `PowerSync.Maui`, expand `PowerSync.Common`'s supported runtimes (#94) --- .github/workflows/build-docs.yml | 3 +- .github/workflows/deploy-docs.yml | 3 +- .github/workflows/dev-packages.yml | 35 +- .github/workflows/release.yml | 53 +- .github/workflows/test.yml | 12 +- .gitignore | 10 +- PowerSync/PowerSync.Common/CHANGELOG.md | 5 +- .../Client/PowerSyncDatabase.cs | 52 +- .../Sync/Bucket/BucketStorageAdapter.cs | 7 +- .../Client/Sync/Bucket/OplogEntry.cs | 9 +- .../Client/Sync/Bucket/SqliteBucketStorage.cs | 3 +- .../Client/Sync/Stream/CoreInstructions.cs | 1 + .../Stream/StreamingSyncImplementation.cs | 145 +++-- .../Client/Sync/Stream/StreamingSyncTypes.cs | 5 + .../PowerSync.Common/Client/WatchManager.cs | 28 +- .../MDSQLite/MDSQLiteAdapter.cs | 32 +- .../PowerSync.Common/PowerSync.Common.csproj | 39 +- .../PowerSync.Common/PowerSync.Common.targets | 7 - .../Utils/Converters/StringLongConverter.cs | 6 +- ...ver.cs => PowerSyncDesktopPathResolver.cs} | 2 +- .../buildTransitive/PowerSync.Common.targets | 21 + PowerSync/PowerSync.Maui/CHANGELOG.md | 5 +- .../PowerSync.Maui/PowerSync.Maui.csproj | 37 +- PowerSync/PowerSync.Maui/README.md | 44 +- .../SQLite/MAUISQLiteAdapter.cs | 55 -- .../SQLite/MAUISQLiteDBOpenFactory.cs | 26 - .../PowerSync.Maui/build/ApiDefinition.cs | 4 - README.md | 40 +- .../Client/PowerSyncDatabaseTests.cs | 16 +- .../Client/Sync/CheckpointRequestsTests.cs | 30 +- .../Client/Sync/SyncTests.cs | 15 + .../MDSQLite/MDSQLiteAdapterTests.cs | 2 +- .../Utils/Sync/MockSyncService.cs | 5 +- Tools/Setup/Setup.cs | 512 +++++++++--------- Tools/Setup/Setup.csproj | 3 +- Tools/Setup/sign-file-list.txt | 3 + demos/MAUITodo/Data/PowerSyncData.cs | 3 +- demos/MAUITodo/MAUITodo.csproj | 4 +- docs/README.md | 2 +- docs/docfx.json | 18 +- docs/filter.yml | 5 - docs/toc.yml | 2 +- root.sln | 15 - 43 files changed, 597 insertions(+), 727 deletions(-) delete mode 100644 PowerSync/PowerSync.Common/PowerSync.Common.targets rename PowerSync/PowerSync.Common/Utils/{PowerSyncPathResolver.cs => PowerSyncDesktopPathResolver.cs} (97%) create mode 100644 PowerSync/PowerSync.Common/buildTransitive/PowerSync.Common.targets delete mode 100644 PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs delete mode 100644 PowerSync/PowerSync.Maui/SQLite/MAUISQLiteDBOpenFactory.cs delete mode 100644 PowerSync/PowerSync.Maui/build/ApiDefinition.cs create mode 100644 Tools/Setup/sign-file-list.txt delete mode 100644 docs/filter.yml diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 2e801bda..459ef788 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -25,8 +25,7 @@ jobs: - name: Restore Projects run: | - dotnet restore PowerSync/PowerSync.Common/PowerSync.Common.csproj -p:TargetFrameworks=net8.0 - dotnet restore PowerSync/PowerSync.Maui/PowerSync.Maui.csproj -p:TargetFrameworks=net8.0 + dotnet restore PowerSync/PowerSync.Common/PowerSync.Common.csproj -p:TargetFrameworks=net10.0 - name: Generate API Metadata working-directory: docs diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 2a4db78b..162608c9 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -31,8 +31,7 @@ jobs: - name: Restore Projects run: | - dotnet restore PowerSync/PowerSync.Common/PowerSync.Common.csproj -p:TargetFrameworks=net8.0 - dotnet restore PowerSync/PowerSync.Maui/PowerSync.Maui.csproj -p:TargetFrameworks=net8.0 + dotnet restore PowerSync/PowerSync.Common/PowerSync.Common.csproj -p:TargetFrameworks=net10.0 - name: Generate API Metadata working-directory: docs diff --git a/.github/workflows/dev-packages.yml b/.github/workflows/dev-packages.yml index e63e2fe6..002b1c84 100644 --- a/.github/workflows/dev-packages.yml +++ b/.github/workflows/dev-packages.yml @@ -24,7 +24,7 @@ jobs: with: dotnet-version: "10.0.x" - - name: Install MAUI Workloads + - name: Install native workloads run: dotnet workload restore - name: Download PowerSync extension @@ -33,9 +33,6 @@ jobs: - name: Restore dependencies run: dotnet restore - - name: Install Android SDK for net8.0-android - run: dotnet build PowerSync/PowerSync.Common -t:InstallAndroidDependencies -f net8.0-android -p:AndroidSdkDirectory="$ANDROID_HOME" -p:AcceptAndroidSdkLicenses=True - - name: Extract Common Package Version from CHANGELOG.md shell: bash run: | @@ -50,20 +47,6 @@ jobs: - name: Run Pack For Common run: dotnet pack PowerSync/PowerSync.Common -c Release -o ${{ github.workspace }}/output - - name: Extract MAUI Package Version from CHANGELOG.md - shell: bash - run: | - MAUI_VERSION=$(awk '/^## [0-9]+\.[0-9]+\.[0-9]+-dev(\.[0-9]+)?$/ {print $2; exit}' PowerSync/PowerSync.Maui/CHANGELOG.md) - if [[ -z "$MAUI_VERSION" ]]; then - echo "Error: Invalid dev version found in PowerSync.Maui/CHANGELOG.md. Expected format: x.x.x-dev.x" - exit 1 - fi - echo "Detected Version: $MAUI_VERSION" - echo "VERSION=$MAUI_VERSION" >> $GITHUB_ENV - - - name: Run Pack For MAUI - run: dotnet pack PowerSync/PowerSync.Maui -c Release -o ${{ github.workspace }}/output - - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: @@ -81,6 +64,11 @@ jobs: contents: read steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + - name: Download build artifacts uses: actions/download-artifact@v4 with: @@ -90,7 +78,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Install Sign CLI tool run: dotnet tool install --tool-path . --prerelease sign @@ -108,6 +96,7 @@ jobs: ./sign code azure-key-vault **/*.nupkg --base-directory "${{ github.workspace }}/BuildArtifacts" + --file-list "${{ github.workspace }}/Tools/Setup/sign-file-list.txt" --publisher-name "PowerSync" --description "PowerSync .NET SDK" --description-url "https://github.com/powersync-ja/powersync-dotnet" @@ -142,7 +131,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Validate artifact signatures run: dotnet nuget verify ${{ github.workspace }}/SignedArtifacts/**/*.nupkg @@ -158,9 +147,3 @@ jobs: dotnet nuget push "${{ github.workspace }}\SignedArtifacts\PowerSync.Common*.nupkg" --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json - - - name: Run Push For Maui - run: > - dotnet nuget push "${{ github.workspace }}\SignedArtifacts\PowerSync.Maui*.nupkg" - --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" - --source https://api.nuget.org/v3/index.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71c0874a..66179050 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,6 @@ jobs: outputs: common_version: ${{ steps.extract_version.outputs.common_version }} - maui_version: ${{ steps.extract_maui_version.outputs.maui_version }} steps: - name: Checkout Repository @@ -28,7 +27,7 @@ jobs: with: dotnet-version: "10.0.x" - - name: Install MAUI Workloads + - name: Install native workloads run: dotnet workload restore - name: Download PowerSync extension @@ -37,9 +36,6 @@ jobs: - name: Restore dependencies run: dotnet restore - - name: Install Android SDK for net8.0-android - run: dotnet build PowerSync/PowerSync.Common -t:InstallAndroidDependencies -f net8.0-android -p:AndroidSdkDirectory="$ANDROID_HOME" -p:AcceptAndroidSdkLicenses=True - - name: Extract Common Package Version from CHANGELOG.md id: extract_version shell: bash @@ -56,22 +52,6 @@ jobs: - name: Run Pack For Common run: dotnet pack PowerSync/PowerSync.Common -c Release -o ${{ github.workspace }}/output - - name: Extract MAUI Package Version from CHANGELOG.md - id: extract_maui_version - shell: bash - run: | - MAUI_VERSION=$(awk '/^## [0-9]+\.[0-9]+\.[0-9]+/ {print $2; exit}' PowerSync/PowerSync.Maui/CHANGELOG.md) - if [[ -z "$MAUI_VERSION" ]]; then - echo "Error: Invalid version found in PowerSync.Maui/CHANGELOG.md. Expected format: '## x.x.x'" - exit 1 - fi - echo "Detected Version: $MAUI_VERSION" - echo "VERSION=$MAUI_VERSION" >> $GITHUB_ENV - echo "maui_version=$MAUI_VERSION" >> $GITHUB_OUTPUT - - - name: Run Pack For MAUI - run: dotnet pack PowerSync/PowerSync.Maui -c Release -o ${{ github.workspace }}/output - - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: @@ -89,6 +69,11 @@ jobs: contents: read steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + - name: Download build artifacts uses: actions/download-artifact@v4 with: @@ -98,7 +83,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Install Sign CLI tool run: dotnet tool install --tool-path . --prerelease sign @@ -116,6 +101,7 @@ jobs: ./sign code azure-key-vault **/*.nupkg --base-directory "${{ github.workspace }}/BuildArtifacts" + --file-list "${{ github.workspace }}/Tools/Setup/sign-file-list.txt" --publisher-name "PowerSync" --description "PowerSync .NET SDK" --description-url "https://github.com/powersync-ja/powersync-dotnet" @@ -150,7 +136,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Validate artifact signatures run: dotnet nuget verify ${{ github.workspace }}/SignedArtifacts/**/*.nupkg @@ -181,24 +167,3 @@ jobs: --target main \ --generate-notes \ SignedArtifacts/PowerSync.Common*.nupkg - - - name: Run Push For Maui - run: > - dotnet nuget push "${{ github.workspace }}\SignedArtifacts\PowerSync.Maui*.nupkg" - --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" - --source https://api.nuget.org/v3/index.json - - - name: Create GitHub Release For Maui - continue-on-error: true - shell: bash - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - MAUI_VERSION: ${{ needs.build.outputs.maui_version }} - run: | - gh release create "PowerSync.Maui@${MAUI_VERSION}" \ - --draft \ - --title "PowerSync.Maui@${MAUI_VERSION}" \ - --target main \ - --generate-notes \ - SignedArtifacts/PowerSync.Maui*.nupkg diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 110b65f1..a2920b91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ on: jobs: build: strategy: - matrix: + matrix: runner: [windows-latest, windows-11-arm] name: Test Packages ${{ matrix.runner }} runs-on: ${{ matrix.runner }} @@ -18,16 +18,16 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v5 with: - dotnet-version: '10.0' + dotnet-version: "10.0" - - name: Install MAUI Workloads + - name: Install native workloads run: dotnet workload restore - + - name: Download PowerSync extension - run: dotnet run --project Tools/Setup + run: dotnet run --project Tools/Setup - name: Restore dependencies run: dotnet restore - + - name: Run tests run: dotnet test -v n --framework net10.0 diff --git a/.gitignore b/.gitignore index 688d8e68..4f214e65 100644 --- a/.gitignore +++ b/.gitignore @@ -53,18 +53,14 @@ TestResults/ *.VSIXProjectLaunch .project .vsconfig +.env *.db *.db-shm *.db-wal -#Core binaries - add rules after ps extension downloading is supported -*.dylib -*.dll -*.so -*.xcframework -.env -*NativeLibs +# PowerSync runtimes installed by Tools/Setup +**/runtimes/ # Ignore user id file user_id.txt diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index 04428fc4..6157adce 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -1,10 +1,11 @@ # PowerSync.Common Changelog -## 1.0.1 (unreleased) +## 1.0.1-dev.2 - Full release. - `PowerSyncDatabase.OnChange` now returns the underlying raw table name instead of the view name to mirror PowerSync JS. -- Use `long` for op IDs instead of `string`. This affects the types returned by some methods used in `PowerSyncBackendConnector.UploadData`, namely `PowerSyncDatabase.GetNextCrudTransaction()` and `PowerSyncDatabase.GetCrudBatch()`. +- Use `long` for op IDs instead of `string`. This affects the types returned by some methods used in `PowerSyncBackendConnector.UploadData`, namely `PowerSyncDatabase.GetNextCrudTransaction()` and `PowerSyncDatabase.GetCrudBatch()`, as well as `ICustomCheckpointRequestConnector.PostCheckpointRequest`. +- Bundle and load native libraries on Android, iOS, and MacCatalyst. This allows `PowerSync.Common` to fully replace `PowerSync.Maui`. ## 1.0.0 (unlisted) diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index 2466bc14..56cd4131 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -154,6 +154,10 @@ public class PowerSyncDatabase : IPowerSyncDatabase protected IBucketStorageAdapter BucketStorageAdapter; + private readonly object currentStatusLock = new(); + /// + /// The latest published sync status. + /// public SyncStatus CurrentStatus { get; protected set; } protected CancellationTokenSource masterCts = new(); @@ -163,6 +167,7 @@ public class PowerSyncDatabase : IPowerSyncDatabase public ILogger Logger { get; protected set; } private readonly AsyncLock runExclusive = new(); + private readonly Func remoteFactory; public StreamingSyncImplementation? SyncStreamImplementation => ConnectionManager.SyncStreamImplementation; @@ -225,6 +230,8 @@ public PowerSyncDatabase(PowerSyncDatabaseOptions options) await WaitForReady(); using (await runExclusive.LockAsync()) { + var syncStreamStatusCts = CancellationTokenSource.CreateLinkedTokenSource(masterCts.Token); + syncStreamImplementation = new StreamingSyncImplementation(new StreamingSyncImplementationOptions { Adapter = BucketStorageAdapter, @@ -245,20 +252,22 @@ public PowerSyncDatabase(PowerSyncDatabaseOptions options) Subscriptions = options.Subscriptions, CrudUploadThrottleMs = options.CrudUploadThrottleMs, TimeProvider = timeProvider, - Logger = Logger - }); - - var syncStreamStatusCts = CancellationTokenSource.CreateLinkedTokenSource(masterCts.Token); - var syncStreamStatusListener = syncStreamImplementation.Events.OnStatusChanged.ListenAsync(syncStreamStatusCts.Token); - var _ = Task.Run(async () => - { - await foreach (var update in syncStreamStatusListener) + Logger = Logger, + OnStatusChanged = status => { - CurrentStatus = new SyncStatus(new SyncStatusOptions(update.Status.Options) + if (syncStreamStatusCts.IsCancellationRequested) { - HasSynced = CurrentStatus?.HasSynced == true || update.Status.LastSyncedAt != null, - }); - Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + return; + } + + lock (currentStatusLock) + { + CurrentStatus = new SyncStatus(new SyncStatusOptions(status.Options) + { + HasSynced = CurrentStatus?.HasSynced == true || status.LastSyncedAt != null, + }); + Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + } } }); @@ -431,12 +440,16 @@ protected async Task ResolveOfflineSyncStatus() var parsed = JsonConvert.DeserializeObject(result.r); var parsedSyncStatus = CoreInstructionHelpers.CoreStatusToSyncStatus(parsed!); - var updatedStatus = CurrentStatus.CreateUpdatedStatus(parsedSyncStatus); - if (!updatedStatus.IsEqual(CurrentStatus)) + lock (currentStatusLock) { - CurrentStatus = updatedStatus; - Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + var updatedStatus = CurrentStatus.CreateUpdatedStatus(parsedSyncStatus); + + if (!updatedStatus.IsEqual(CurrentStatus)) + { + CurrentStatus = updatedStatus; + Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + } } } @@ -531,8 +544,11 @@ await Database.WriteTransaction(async tx => }); // The data has been deleted - reset the sync status - CurrentStatus = new SyncStatus(new SyncStatusOptions()); - Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + lock (currentStatusLock) + { + CurrentStatus = new SyncStatus(new SyncStatusOptions()); + Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + } } /// diff --git a/PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs b/PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs index 23a158e6..353c9c2b 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs @@ -8,6 +8,7 @@ namespace PowerSync.Common.Client.Sync.Bucket; using PowerSync.Common.DB.Crud; using PowerSync.Common.Utils; +using PowerSync.Common.Utils.Converters; public static class PowerSyncControlCommand { @@ -34,13 +35,15 @@ public static class PowerSyncControlConnectionState public class Checkpoint { [JsonProperty("last_op_id")] - public string LastOpId { get; set; } = null!; + [JsonConverter(typeof(StringLongConverter))] + public long LastOpId { get; set; } [JsonProperty("buckets")] public BucketChecksum[] Buckets { get; set; } = []; [JsonProperty("write_checkpoint")] - public string? WriteCheckpoint { get; set; } = null; + [JsonConverter(typeof(StringLongConverter))] + public long? WriteCheckpoint { get; set; } = null; [JsonProperty("streams")] public object[]? Streams { get; set; } = []; diff --git a/PowerSync/PowerSync.Common/Client/Sync/Bucket/OplogEntry.cs b/PowerSync/PowerSync.Common/Client/Sync/Bucket/OplogEntry.cs index f83f4a64..c89ff2ae 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Bucket/OplogEntry.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Bucket/OplogEntry.cs @@ -2,6 +2,8 @@ namespace PowerSync.Common.Client.Sync.Bucket; using Newtonsoft.Json; +using PowerSync.Common.Utils.Converters; + public class OplogEntryJSON { [JsonProperty("checksum")] @@ -17,7 +19,8 @@ public class OplogEntryJSON public string? ObjectType { get; set; } [JsonProperty("op_id")] - public string OpId { get; set; } = null!; + [JsonConverter(typeof(StringLongConverter))] + public long OpId { get; set; } [JsonProperty("op")] public string Op { get; set; } = null!; @@ -27,7 +30,7 @@ public class OplogEntryJSON } public class OplogEntry( - string opId, + long opId, OpType op, long checksum, string subkey, @@ -36,7 +39,7 @@ public class OplogEntry( object? data = null ) { - public string OpId { get; private set; } = opId; + public long OpId { get; private set; } = opId; public OpType Op { get; private set; } = op; public long Checksum { get; private set; } = checksum; public string Subkey { get; private set; } = subkey; diff --git a/PowerSync/PowerSync.Common/Client/Sync/Bucket/SqliteBucketStorage.cs b/PowerSync/PowerSync.Common/Client/Sync/Bucket/SqliteBucketStorage.cs index 21dbd193..75e92cd7 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Bucket/SqliteBucketStorage.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Bucket/SqliteBucketStorage.cs @@ -33,9 +33,10 @@ public SqliteBucketStorage(IDBAdapter db, ILogger? logger = null) updateCts = new CancellationTokenSource(); + var updates = db.Events.OnTablesUpdated.Listen(updateCts.Token); updateTask = Task.Run(() => { - foreach (var update in db.Events.OnTablesUpdated.Listen(updateCts.Token)) + foreach (var update in updates) { var tables = DBAdapterUtils.ExtractTableUpdates(update.TablesUpdated); if (tables.Contains(PSInternalTable.CRUD)) diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/CoreInstructions.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/CoreInstructions.cs index efc5adf7..c17c49c2 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/CoreInstructions.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/CoreInstructions.cs @@ -135,6 +135,7 @@ public class CoreSyncStatus public List Streams { get; set; } = []; [JsonProperty("internal_last_applied_checkpoint_request_id")] + [JsonConverter(typeof(StringLongConverter))] public long? LastAppliedCheckpointRequestId { get; set; } } diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs index 3234950c..443d2e7d 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs @@ -13,6 +13,7 @@ namespace PowerSync.Common.Client.Sync.Stream; using PowerSync.Common.Client.Sync.Bucket; using PowerSync.Common.DB.Crud; using PowerSync.Common.Utils; +using PowerSync.Common.Utils.Converters; public class AdditionalConnectionOptions(int? retryDelayMs = null, int? crudUploadThrottleMs = null) { @@ -61,6 +62,13 @@ public class StreamingSyncImplementationOptions : AdditionalConnectionOptions public ILogger? Logger { get; init; } + /// + /// Called synchronously whenever the sync status changes, before the change is published on + /// . It runs while the status lock is held, so it + /// must not block or call back into the sync implementation. + /// + internal Action? OnStatusChanged { get; init; } + /// /// Source of the delays in the sync loops. Tests substitute a fake clock so they don't have to /// wait out real retry delays. @@ -193,8 +201,10 @@ public class StreamingSyncImplementation : ICloseable /// /// The highest checkpoint request id the core extension has reported as applied, if any. + /// Updated in the download loop and read in the repost loop. /// - internal long? LastAppliedCheckpointRequestId; + internal long? _lastAppliedCheckpointRequestId; + private readonly object _lastAppliedCheckpointLock = new(); private readonly ILogger logger; private SubscribedStream[] activeStreams; @@ -215,7 +225,7 @@ public class StreamingSyncImplementation : ICloseable public StreamingSyncImplementation(StreamingSyncImplementationOptions options) { Options = options; - SyncStatus = new SyncStatus(new SyncStatusOptions + _syncStatus = new SyncStatus(new SyncStatusOptions { Connected = false, Connecting = false, @@ -234,6 +244,23 @@ public StreamingSyncImplementation(StreamingSyncImplementationOptions options) CancellationTokenSource = null; } + private SyncStatus _syncStatus; + private readonly object _syncStatusLock = new(); + + /// + /// The current synchronization status. + /// + public SyncStatus SyncStatus + { + get + { + lock (_syncStatusLock) + { + return _syncStatus; + } + } + } + /// /// Indicates if the sync service is connected. /// @@ -244,11 +271,6 @@ public StreamingSyncImplementation(StreamingSyncImplementationOptions options) /// public DateTime? LastSyncedAt => SyncStatus.LastSyncedAt; - /// - /// The current synchronization status. - /// - public SyncStatus SyncStatus { get; protected set; } - public async Task Connect(PowerSyncConnectionOptions? options = null) { if (CancellationTokenSource != null) @@ -260,25 +282,41 @@ public async Task Connect(PowerSyncConnectionOptions? options = null) var tcs = new TaskCompletionSource(); var cts = new CancellationTokenSource(); - // Subscribe to events before starting StreamingSync to not miss the Connected == true event + // Subscribe to events before starting StreamingSync to not miss the first status change var listener = Events.OnStatusChanged.ListenAsync(cts.Token); streamingSyncTask = StreamingSync(CancellationTokenSource.Token, options); + // Resolves once the connection attempt has settled, which is when `Connecting` has been seen + // as true and became false again. A download error settles the attempt too: the download loop + // keeps retrying after one, and connecting shouldn't block for the whole retry loop. var _ = Task.Run(async () => { + var sawStartOfConnection = false; + await foreach (var status in listener) { - if (status.Status.Connected == true) + if (status.Status.Connecting) { - tcs.TrySetResult(true); - cts.Cancel(); - return; + sawStartOfConnection = true; + } + + if (status.Status.DataFlowStatus.DownloadError != null) + { + logger.LogWarning("Initial connect attempt did not successfully connect to server"); + } + else if (!sawStartOfConnection || status.Status.Connecting) + { + // Still connecting. + continue; } + + tcs.TrySetResult(true); + cts.Cancel(); + return; } // Connection closed prematurely - logger.LogWarning("Initial connect attempt did not successfully connect to server"); tcs.TrySetResult(true); }); @@ -423,9 +461,10 @@ protected async Task DownloadLoop(CancellationToken signal, PowerSyncConnectionO var retryDelayMs = options.RetryDelayMs ?? DEFAULT_RETRY_DELAY_MS; crudUpdateCts = new CancellationTokenSource(); + var crudUpdates = Options.Adapter.Events.OnCrudUpdate.ListenAsync(crudUpdateCts.Token); crudUpdateTask = Task.Run(async () => { - await foreach (var _ in Options.Adapter.Events.OnCrudUpdate.ListenAsync(crudUpdateCts.Token)) + await foreach (var _ in crudUpdates) { TriggerCrudUpload(); } @@ -655,7 +694,10 @@ protected async Task RepostUnacknowledgedCheckpointRequests(CancellationToken si /// internal bool IsCheckpointRequestApplied(long requestId) { - return LastAppliedCheckpointRequestId is not null && LastAppliedCheckpointRequestId >= requestId; + lock (_lastAppliedCheckpointLock) + { + return _lastAppliedCheckpointRequestId is not null && _lastAppliedCheckpointRequestId >= requestId; + } } protected record StreamingSyncIterationResult @@ -838,7 +880,10 @@ async Task HandleInstruction(NonInterruptingInstruction instruction) } break; case UpdateSyncStatus syncStatus: - LastAppliedCheckpointRequestId = syncStatus.Status.LastAppliedCheckpointRequestId; + lock (_lastAppliedCheckpointLock) + { + _lastAppliedCheckpointRequestId = syncStatus.Status.LastAppliedCheckpointRequestId; + } UpdateSyncStatus(CoreInstructionHelpers.CoreStatusToSyncStatusOptions(syncStatus.Status)); break; case FetchCredentials fetchCredentials: @@ -1197,40 +1242,50 @@ protected void UpdateSyncStatus(SyncStatusOptions options, UpdateSyncStatusOptio { try { - var updatedStatus = new SyncStatus(new SyncStatusOptions + lock (_syncStatusLock) { - Connected = options.Connected ?? SyncStatus.Connected, - Connecting = !options.Connected.GetValueOrDefault() && (options.Connecting ?? SyncStatus.Connecting), - LastSyncedAt = options.LastSyncedAt ?? SyncStatus.LastSyncedAt, - PriorityStatusEntries = options.PriorityStatusEntries ?? SyncStatus.PriorityStatusEntries, - DataFlow = new SyncDataFlowStatus + var updatedStatus = new SyncStatus(new SyncStatusOptions { - Uploading = options.DataFlow?.Uploading ?? SyncStatus.DataFlowStatus.Uploading, - Downloading = options.DataFlow?.Downloading ?? SyncStatus.DataFlowStatus.Downloading, - DownloadProgress = options.DataFlow?.DownloadProgress ?? SyncStatus.DataFlowStatus.DownloadProgress, - DownloadError = updateOptions?.ClearDownloadError == true ? null : options.DataFlow?.DownloadError ?? SyncStatus.DataFlowStatus.DownloadError, - UploadError = updateOptions?.ClearUploadError == true ? null : options.DataFlow?.UploadError ?? SyncStatus.DataFlowStatus.UploadError, - InternalStreamSubscriptions = options.DataFlow?.InternalStreamSubscriptions ?? SyncStatus.DataFlowStatus.InternalStreamSubscriptions - } - }); + Connected = options.Connected ?? _syncStatus.Connected, + Connecting = !options.Connected.GetValueOrDefault() && (options.Connecting ?? _syncStatus.Connecting), + LastSyncedAt = options.LastSyncedAt ?? _syncStatus.LastSyncedAt, + PriorityStatusEntries = options.PriorityStatusEntries ?? _syncStatus.PriorityStatusEntries, + DataFlow = new SyncDataFlowStatus + { + Uploading = options.DataFlow?.Uploading ?? _syncStatus.DataFlowStatus.Uploading, + Downloading = options.DataFlow?.Downloading ?? _syncStatus.DataFlowStatus.Downloading, + DownloadProgress = options.DataFlow?.DownloadProgress ?? _syncStatus.DataFlowStatus.DownloadProgress, + DownloadError = updateOptions?.ClearDownloadError == true ? null : options.DataFlow?.DownloadError ?? _syncStatus.DataFlowStatus.DownloadError, + UploadError = updateOptions?.ClearUploadError == true ? null : options.DataFlow?.UploadError ?? _syncStatus.DataFlowStatus.UploadError, + InternalStreamSubscriptions = options.DataFlow?.InternalStreamSubscriptions ?? _syncStatus.DataFlowStatus.InternalStreamSubscriptions + } + }); - if (!SyncStatus.Equals(updatedStatus)) - { - SyncStatus = updatedStatus; - logger.LogDebug("[Sync status changed]: {message}", updatedStatus.ToJSON()); + // TODO: Always false because Equals is reference equality. + // A SyncStatus.IsEqual method exists, but is very inefficient. + if (!_syncStatus.Equals(updatedStatus)) + { + _syncStatus = updatedStatus; - // Emit events using new SyncStatus objects to prevent local modifications propagating to StreamingSyncImplementation + // Suppress CA1873 (expensive arguments to logging function) + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug("[Sync status changed]: {message}", updatedStatus.ToJSON()); - // Only trigger this if there was a change - Events.Emit(new StreamingSyncImplementationEvents.StatusChangedEvent(new SyncStatus(updatedStatus.Options))); + // Emit events using new _syncStatus objects to prevent local modifications propagating to StreamingSyncImplementation - // Emit StatusUpdated event wrapping a new SyncStatus object (prevents race conditions) - Events.Emit(new StreamingSyncImplementationEvents.StatusUpdatedEvent(new SyncStatus(updatedStatus.Options))); - } - else - { - // Emit StatusUpdated event directly wrapping `updatedStatus` (not exposed elsewhere) - Events.Emit(new StreamingSyncImplementationEvents.StatusUpdatedEvent(updatedStatus)); + Options.OnStatusChanged?.Invoke(new SyncStatus(updatedStatus.Options)); + + // Only trigger this if there was a change + Events.Emit(new StreamingSyncImplementationEvents.StatusChangedEvent(new SyncStatus(updatedStatus.Options))); + + // Emit StatusUpdated event wrapping a new SyncStatus object (prevents race conditions) + Events.Emit(new StreamingSyncImplementationEvents.StatusUpdatedEvent(new SyncStatus(updatedStatus.Options))); + } + else + { + // Emit StatusUpdated event directly wrapping `updatedStatus` (not exposed elsewhere) + Events.Emit(new StreamingSyncImplementationEvents.StatusUpdatedEvent(updatedStatus)); + } } } catch (Exception ex) @@ -1305,7 +1360,7 @@ private static double WithJitter(int delayMs) } internal record LegacyWriteCheckpointResponseData( - [property: JsonProperty("write_checkpoint")] long WriteCheckpoint + [property: JsonProperty("write_checkpoint"), JsonConverter(typeof(StringLongConverter))] long WriteCheckpoint ); internal record LegacyWriteCheckpointApiResponse( [property: JsonProperty("data")] LegacyWriteCheckpointResponseData Data diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncTypes.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncTypes.cs index 71694c77..11c501cc 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncTypes.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncTypes.cs @@ -1,5 +1,6 @@ using PowerSync.Common.Client.Sync.Bucket; using PowerSync.Common.DB.Crud; +using PowerSync.Common.Utils.Converters; using Newtonsoft.Json; @@ -128,6 +129,7 @@ public class StreamingSyncCheckpointDiff : StreamingSyncLine public class CheckpointDiff { [JsonProperty("last_op_id")] + [JsonConverter(typeof(StringLongConverter))] public long LastOpId { get; set; } [JsonProperty("updated_buckets")] @@ -137,6 +139,7 @@ public class CheckpointDiff public List RemovedBuckets { get; set; } = []; [JsonProperty("write_checkpoint")] + [JsonConverter(typeof(StringLongConverter))] public long WriteCheckpoint { get; set; } } @@ -197,6 +200,7 @@ public class CheckpointRequestPayload public string ClientId { get; set; } = ""; [JsonProperty("checkpoint_request_id")] + [JsonConverter(typeof(StringLongConverter))] public long CheckpointRequestId { get; set; } } @@ -209,5 +213,6 @@ public class CheckpointRequestResponse public class CheckpointRequestResponseData { [JsonProperty("checkpoint_request_id")] + [JsonConverter(typeof(StringLongConverter))] public long CheckpointRequestId { get; set; } } diff --git a/PowerSync/PowerSync.Common/Client/WatchManager.cs b/PowerSync/PowerSync.Common/Client/WatchManager.cs index 4df2210b..cbe1f4da 100644 --- a/PowerSync/PowerSync.Common/Client/WatchManager.cs +++ b/PowerSync/PowerSync.Common/Client/WatchManager.cs @@ -32,9 +32,12 @@ public WatchManager(PowerSyncDatabase db, CancellationToken masterToken) this.db = db; this.masterToken = masterToken; + var tableUpdates = db.Database.Events.OnTablesUpdated.ListenAsync(masterToken); + var schemaChanges = db.Events.OnSchemaChanged.ListenAsync(masterToken); + // Dispatch two tasks, one for TableUpdated events and one for SchemaChanged events - _ = Task.Run(RunTableUpdateLoop); - _ = Task.Run(RunSchemaChangeLoop); + _ = Task.Run(() => RunTableUpdateLoop(tableUpdates)); + _ = Task.Run(() => RunSchemaChangeLoop(schemaChanges)); } public IAsyncEnumerable Watch(string sql, object?[]? parameters, SQLWatchOptions? options) @@ -138,11 +141,11 @@ private async IAsyncEnumerable Stream(WatchSubscription subscription, Func } } - private async Task RunTableUpdateLoop() + private async Task RunTableUpdateLoop(IAsyncEnumerable updates) { try { - await foreach (var update in db.Database.Events.OnTablesUpdated.ListenAsync(masterToken)) + await foreach (var update in updates) { // Prevent a single bad notification from taking down the entire TablesUpdated loop try @@ -176,11 +179,11 @@ private async Task RunTableUpdateLoop() } } - private async Task RunSchemaChangeLoop() + private async Task RunSchemaChangeLoop(IAsyncEnumerable schemaChanges) { try { - await foreach (var _ in db.Events.OnSchemaChanged.ListenAsync(masterToken)) + await foreach (var _ in schemaChanges) { foreach (var entry in watches) { @@ -196,18 +199,7 @@ private async Task RunSchemaChangeLoop() } private static HashSet ExpandTableNames(IEnumerable tables) => - [.. tables.SelectMany(table => new[] { $"{PS_DATA_PREFIX}{table}", $"{PS_DATA_LOCAL_PREFIX}{table}" })]; - - private static string InternalToFriendlyTableName(string internalName) - { - if (internalName.StartsWith(PS_DATA_PREFIX)) - return internalName.Substring(PS_DATA_PREFIX.Length); - - if (internalName.StartsWith(PS_DATA_LOCAL_PREFIX)) - return internalName.Substring(PS_DATA_LOCAL_PREFIX.Length); - - return internalName; - } + [.. tables.SelectMany(table => new[] { $"{PS_DATA_PREFIX}{table}", $"{PS_DATA_LOCAL_PREFIX}{table}", $"{table}" })]; } /// diff --git a/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs b/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs index cdb4ec9e..6055c929 100644 --- a/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs +++ b/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs @@ -12,6 +12,10 @@ namespace PowerSync.Common.MDSQLite; using PowerSync.Common.DB; using PowerSync.Common.Utils; +#if IOS || MACCATALYST +using Foundation; +#endif + public class MDSQLiteAdapterOptions() { public string Name { get; set; } = null!; @@ -111,9 +115,10 @@ private async Task Init() // Register TablesUpdated listener tablesUpdatedCts = new CancellationTokenSource(); + var notifications = writeConnection.ListenAsync(tablesUpdatedCts.Token); tablesUpdatedTask = Task.Run(async () => { - await foreach (var notification in writeConnection.ListenAsync(tablesUpdatedCts.Token)) + await foreach (var notification in notifications) { if (notification.TablesUpdated != null) { @@ -154,13 +159,30 @@ protected virtual void LoadExtensions(SqliteConnection db) /// /// Loads the bundled PowerSync core SQLite extension. Override on - /// platform-specific adapters (e.g. MAUI iOS/Android) where the native library - /// lives outside the desktop runtime path. + /// platform-specific adapters where the native library lives + /// outside the standard runtimes path. /// protected virtual void LoadDefaultPowerSyncExtension(SqliteConnection db) { - var path = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); - db.LoadExtension(path, "sqlite3_powersync_init"); + string extensionPath; + +#if IOS || MACCATALYST + var bundlePath = + NSBundle.FromIdentifier("co.powersync.sqlitecore")?.BundlePath + ?? throw new Exception("Could not find PowerSync SQLite extension bundle path"); + extensionPath = Path.Combine(bundlePath, "powersync-sqlite-core"); +#elif ANDROID + extensionPath = "libpowersync"; +#else + extensionPath = PowerSyncDesktopPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); +#endif + + // Use manual command instead of db.LoadExtension(), since that has issues on iOS + using var loadExtension = db.CreateCommand(); + loadExtension.CommandText = "SELECT load_extension(@path, @entryPoint)"; + loadExtension.Parameters.AddWithValue("@path", extensionPath); + loadExtension.Parameters.AddWithValue("@entryPoint", "sqlite3_powersync_init"); + loadExtension.ExecuteNonQuery(); } public async Task Close() diff --git a/PowerSync/PowerSync.Common/PowerSync.Common.csproj b/PowerSync/PowerSync.Common/PowerSync.Common.csproj index 01794909..6fb77ba4 100644 --- a/PowerSync/PowerSync.Common/PowerSync.Common.csproj +++ b/PowerSync/PowerSync.Common/PowerSync.Common.csproj @@ -39,26 +39,39 @@ + - - + - - - - - PreserveNewest - + + + - - - - + + + + + + + + + + + + + + + - + + diff --git a/PowerSync/PowerSync.Common/PowerSync.Common.targets b/PowerSync/PowerSync.Common/PowerSync.Common.targets deleted file mode 100644 index 0c5bcba2..00000000 --- a/PowerSync/PowerSync.Common/PowerSync.Common.targets +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/PowerSync/PowerSync.Common/Utils/Converters/StringLongConverter.cs b/PowerSync/PowerSync.Common/Utils/Converters/StringLongConverter.cs index 61c7cbdf..73f092ed 100644 --- a/PowerSync/PowerSync.Common/Utils/Converters/StringLongConverter.cs +++ b/PowerSync/PowerSync.Common/Utils/Converters/StringLongConverter.cs @@ -6,10 +6,6 @@ namespace PowerSync.Common.Utils.Converters; /// Converts a long to and from a string when converting JSON values. Used /// for converting checkpoint request IDs from a long to a string before being /// passed to the core extension. -/// -/// TODO: This is not currently in use because checkpoint request IDs are -/// currently represented as strings, however this is going to change -/// in the 1.0 release. /// internal class StringLongConverter : JsonConverter { @@ -42,6 +38,6 @@ public override object ReadJson(JsonReader reader, Type objectType, object? exis return result; } - throw new JsonSerializationException($"Cannot convert value {val} to long."); + throw new JsonSerializationException($"Cannot convert value '{val}' to long."); } } diff --git a/PowerSync/PowerSync.Common/Utils/PowerSyncPathResolver.cs b/PowerSync/PowerSync.Common/Utils/PowerSyncDesktopPathResolver.cs similarity index 97% rename from PowerSync/PowerSync.Common/Utils/PowerSyncPathResolver.cs rename to PowerSync/PowerSync.Common/Utils/PowerSyncDesktopPathResolver.cs index 8aa7c3a4..f6c07f77 100644 --- a/PowerSync/PowerSync.Common/Utils/PowerSyncPathResolver.cs +++ b/PowerSync/PowerSync.Common/Utils/PowerSyncDesktopPathResolver.cs @@ -2,7 +2,7 @@ namespace PowerSync.Common.Utils; using System.Runtime.InteropServices; -public static class PowerSyncPathResolver +public static class PowerSyncDesktopPathResolver { public static string GetNativeLibraryPath(string packagePath) { diff --git a/PowerSync/PowerSync.Common/buildTransitive/PowerSync.Common.targets b/PowerSync/PowerSync.Common/buildTransitive/PowerSync.Common.targets new file mode 100644 index 00000000..4f1aa9d5 --- /dev/null +++ b/PowerSync/PowerSync.Common/buildTransitive/PowerSync.Common.targets @@ -0,0 +1,21 @@ + + + + + <_PowerSyncUnusableRuntimeTarget Include="@(RuntimeTargetsCopyLocalItems)" + Condition="'%(NuGetPackageId)' == 'PowerSync.Common' And !$([System.Text.RegularExpressions.Regex]::IsMatch('%(RuntimeIdentifier)', '^(win|linux|osx)'))" /> + + + + diff --git a/PowerSync/PowerSync.Maui/CHANGELOG.md b/PowerSync/PowerSync.Maui/CHANGELOG.md index 72f1d515..fef39818 100644 --- a/PowerSync/PowerSync.Maui/CHANGELOG.md +++ b/PowerSync/PowerSync.Maui/CHANGELOG.md @@ -1,9 +1,8 @@ # PowerSync.Maui Changelog -## 1.0.1 +## 1.0.1-dev.2 -- Full release. -- Upstream PowerSync.Common version bump (See Powersync.Common changelog 1.0.1 for more information) +- ⚠️ Deprecate `PowerSync.Maui`. Use `PowerSync.Common` and `MDSQLiteAdapter` / `MDSQLiteDBOpenFactory` instead. ## 1.0.0 (unlisted) diff --git a/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj b/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj index f8864efb..552547ce 100644 --- a/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj +++ b/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj @@ -1,5 +1,4 @@  - net9.0;net10.0;net9.0-android;net10.0-android $(TargetFrameworks);net9.0-ios;net10.0-ios;net9.0-maccatalyst;net10.0-maccatalyst @@ -19,7 +18,6 @@ https://github.com/powersync-ja/powersync-dotnet/PowerSync/PowerSync.Maui/CHANGELOG.md powersync local-first local-storage state-management offline sql db persistence sqlite sync icon.png - NU5100;1591 README.md true true @@ -27,44 +25,11 @@ - + - - Designer - - - - - - - - - Framework - False - - - - - - - Framework - False - - - - - - - - - - diff --git a/PowerSync/PowerSync.Maui/README.md b/PowerSync/PowerSync.Maui/README.md index bc75882c..af6137a4 100644 --- a/PowerSync/PowerSync.Maui/README.md +++ b/PowerSync/PowerSync.Maui/README.md @@ -1,45 +1,3 @@ # PowerSync SDK .NET MAUI -This package provides .NET Multi-platform App UI (MAUI) integration for PowerSync, designed to work with the PowerSync.Common package for cross-platform mobile and desktop applications. - -## ⚠️ Project Status & Release Note - -This package is in beta and is considered ready for production use for tested use cases. See our feature status definitions [here](https://docs.powersync.com/resources/feature-status). - -## Installation - -This package is published on [NuGet](https://www.nuget.org/packages/PowerSync.Maui) and requires PowerSync.Common to also be installed. - -```bash -dotnet add package PowerSync.Maui -dotnet add package PowerSync.Common -``` - -## Usage - -Initialization differs slightly from our Common SDK when using MAUI. - -```csharp - -private record ListResult(string id, string name, string owner_id, string created_at); - -static async Task Main() { - - // Ensures the DB file is stored in a platform appropriate location - var dbPath = Path.Combine(FileSystem.AppDataDirectory, "maui-example.db"); - var factory = new MAUISQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions() - { - DbFilename = dbPath - }); - - var Db = new PowerSyncDatabase(new PowerSyncDatabaseOptions() - { - Database = factory, // Supply a factory - Schema = AppSchema.PowerSyncSchema, - }); - - await db.Init(); - - var lists = await db.GetAll("select * from lists"); -} -``` +- ⚠️ This package is deprecated. Use `PowerSync.Common` and `MDSQLiteAdapter` / `MDSQLiteDBOpenFactory` instead. diff --git a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs b/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs deleted file mode 100644 index 0d6e3993..00000000 --- a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs +++ /dev/null @@ -1,55 +0,0 @@ -namespace PowerSync.Maui.SQLite; - -using Microsoft.Data.Sqlite; - -using PowerSync.Common.MDSQLite; - -// iOS/MacCatalyst specific imports -#if IOS || MACCATALYST -using Foundation; -#endif - -public class MAUISQLiteAdapter : MDSQLiteAdapter -{ - public MAUISQLiteAdapter(MDSQLiteAdapterOptions options) : base(options) - { - } - - // The bundled PowerSync extension lives in a platform-specific location on - // iOS/MacCatalyst/Android — the desktop runtime path used by the base class - // does not resolve to it. Override only the PowerSync-extension load hook; - // user-supplied custom extensions still flow through MDSQLiteAdapter.LoadExtensions - // unchanged, so consumers can freely combine the bundled extension (via the - // LoadPowerSyncExtension flag) with their own. - protected override void LoadDefaultPowerSyncExtension(SqliteConnection db) - { -#if IOS || MACCATALYST - LoadExtensionApple(db); -#elif ANDROID - db.LoadExtension("libpowersync"); -#else - base.LoadDefaultPowerSyncExtension(db); -#endif - } - - private static void LoadExtensionApple(SqliteConnection db) - { -#if IOS || MACCATALYST - var bundlePath = Foundation.NSBundle.FromIdentifier("co.powersync.sqlitecore")?.BundlePath; - if (bundlePath == null) - { - throw new Exception("Could not find PowerSync SQLite extension bundle path"); - } - - var filePath = - Path.Combine(bundlePath, "powersync-sqlite-core"); - - using var loadExtension = db.CreateCommand(); - loadExtension.CommandText = "SELECT load_extension(@path, @entryPoint)"; - loadExtension.Parameters.AddWithValue("@path", filePath); - loadExtension.Parameters.AddWithValue("@entryPoint", "sqlite3_powersync_init"); - loadExtension.ExecuteNonQuery(); -#endif - } -} - diff --git a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteDBOpenFactory.cs b/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteDBOpenFactory.cs deleted file mode 100644 index c8d35088..00000000 --- a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteDBOpenFactory.cs +++ /dev/null @@ -1,26 +0,0 @@ -using PowerSync.Common.DB; - -namespace PowerSync.Maui.SQLite; - -using PowerSync.Common.Client; -using PowerSync.Common.MDSQLite; - - -public class MAUISQLiteDBOpenFactory : ISQLOpenFactory -{ - private readonly MDSQLiteOpenFactoryOptions options; - - public MAUISQLiteDBOpenFactory(MDSQLiteOpenFactoryOptions options) - { - this.options = options; - } - - public IDBAdapter OpenDatabase() - { - return new MAUISQLiteAdapter(new MDSQLiteAdapterOptions - { - Name = options.DbFilename, - SqliteOptions = options.SqliteOptions - }); - } -} diff --git a/PowerSync/PowerSync.Maui/build/ApiDefinition.cs b/PowerSync/PowerSync.Maui/build/ApiDefinition.cs deleted file mode 100644 index 5fe8ef43..00000000 --- a/PowerSync/PowerSync.Maui/build/ApiDefinition.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace PowerSync.Maui.build -{ - // Empty API definition - allows xcframework to be included without managed bindings -} diff --git a/README.md b/README.md index a48185b4..af113abf 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,6 @@ Packages are published to [NuGet](https://www.nuget.org/profiles/PowerSync). - [PowerSync.Common](./PowerSync/PowerSync.Common/README.md) - Core package: .NET implementation of a PowerSync database connector and streaming sync bucket implementation. Packages meant for specific platforms will extend functionality of `Common`. -- [PowerSync.Maui](./PowerSync/PowerSync.Maui/README.md) - - Extends the PowerSync.Common package to provide the .NET Multi-platform App UI (MAUI) integration for PowerSync for cross-platform mobile and desktop applications. ## Demo Apps / Example Projects @@ -31,41 +29,9 @@ Demo applications are located in the [`demos/`](./demos/) directory. Also see ou This PowerSync SDK supports the following target frameworks: -- **.NET 9** - [Latest version](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) -- **.NET 8** - [Current LTS Version, used for development of this project](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) -- **.NET 6** - supported for compatibility with older projects -- **.NET Standard 2.0** - for compatibility with older libraries and frameworks, tested/verified older versions will be listed below. - -- .NET Framework 4.8: - - To get a .NET Framework 4.8 working with this SDK add the following to your `.csproj` file: - - ```xml - - ... - - win-x86;win-x64 - win-x64 - - - - ... - - - - ``` - - and create a `IsExternalInit.cs` file in your project with the following contents: - - ```cs - using System.ComponentModel; - - namespace System.Runtime.CompilerServices - { - [EditorBrowsable(EditorBrowsableState.Never)] - internal class IsExternalInit { } - } - ``` +- **.NET 10** - [Current LTS Version, used for development of this project](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- **.NET 9** - [Current STS Version](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) +- **.NET 8** - [Previous LTS Version, supported until November 10, 2026](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) --- diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index 840cbfd2..8909886d 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -302,7 +302,7 @@ await db.Execute( Assert.All(ids, n => Assert.Equal(id, n)); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task ReadWhileWriteIsRunning() { var sem = new TaskCompletionSource(); @@ -357,7 +357,7 @@ public async Task BatchExecute() Assert.Equal(make2, result[1].make); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task QueueSimultaneousExecutions() { var order = new List(); @@ -376,7 +376,7 @@ await db.WriteLock(async context => Assert.Equal(expectedOrder, order); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task CallUpdateHookOnChanges() { var result = new TaskCompletionSource(); @@ -401,7 +401,7 @@ public async Task CallUpdateHookOnChanges() await result.Task; } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task ReflectWriteTransactionUpdatesOnReadConnections() { var watched = new TaskCompletionSource(); @@ -584,7 +584,7 @@ await db.Execute( Assert.Equal(make, dynamicAsset?.make); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task Watch_Cancelled() { int callCount = 0; @@ -616,7 +616,7 @@ public async Task Watch_Cancelled() Assert.Equal(2, callCount); } - [Fact(Timeout = 3000)] + [Fact(Timeout = 5000)] public async Task Watch_MultipleCancelled() { int callCount = 0; @@ -667,7 +667,7 @@ void RunQuery(CancellationTokenSource cts, SemaphoreSlim sem) Assert.Equal(3, callCount); } - [Fact(Timeout = 3000)] + [Fact(Timeout = 5000)] public async Task Watch_SchemaReset() { var dbId = Guid.NewGuid().ToString(); @@ -842,7 +842,7 @@ public async Task Watch_TriggerImmediately_False() Assert.Equal(3, current[0].count); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task Watch_CancelsOnTokenCancellation() { var tcs = new TaskCompletionSource(); diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CheckpointRequestsTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CheckpointRequestsTests.cs index 01227bfb..50f149c7 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CheckpointRequestsTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CheckpointRequestsTests.cs @@ -68,9 +68,9 @@ public async Task CheckpointRequests_RequestsCheckpointsForUpdates() { Checkpoint = new() { - LastOpId = "1", + LastOpId = 1, Buckets = [MockDataFactory.Bucket("a", 1, subscriptions: Array.Empty())], - WriteCheckpoint = _syncService.LastWriteCheckpoint.ToString(), + WriteCheckpoint = _syncService.LastWriteCheckpoint, } }); _syncService.PushLine(new StreamingSyncDataJSON @@ -82,7 +82,7 @@ public async Task CheckpointRequests_RequestsCheckpointsForUpdates() new OplogEntryJSON { Checksum = 0, - OpId = "1", + OpId = 1, ObjectId = "id", ObjectType = "lists", Op = "REMOVE", @@ -143,9 +143,9 @@ public async Task CheckpointRequests_RepostsCurrentCheckpointUntilApplied() { Checkpoint = new() { - LastOpId = "0", + LastOpId = 0, Buckets = [], - WriteCheckpoint = _syncService.LastWriteCheckpoint.ToString(), + WriteCheckpoint = _syncService.LastWriteCheckpoint, } }); _syncService.PushLine(new StreamingSyncCheckpointComplete { CheckpointComplete = new() { LastOpId = "0" } }); @@ -217,10 +217,7 @@ public async Task CheckpointRequests_DownloadIsRetriedOnCheckpointRequest() var iterationsBefore = _syncService.Requests.Count; // Destroy the connection by sending a bogus line. - _syncService.PushLine(new StreamingSyncCheckpoint - { - Checkpoint = new() { LastOpId = "invalid line", Buckets = [] } - }); + _syncService.PushLine("""{"checkpoint":{"last_op_id":"invalid line","buckets":[]}}"""); await TestUtils.WaitForAsync(() => _db.CurrentStatus.DataFlowStatus.DownloadError != null); var start = DateTime.UtcNow; @@ -295,7 +292,7 @@ public async Task CheckpointRequests_ReadsSyncLinesBeforeCheckpointRequestsAreRe _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new() { LastOpId = "0", Buckets = [], WriteCheckpoint = "1" } + Checkpoint = new() { LastOpId = 0, Buckets = [], WriteCheckpoint = 1 } }); await TestUtils.WaitForAsync(() => _db.CurrentStatus.DataFlowStatus.Downloading); @@ -326,7 +323,7 @@ public async Task CheckpointRequests_WaitsUntilDataIsApplied() var checkpoint = await _db.RequestCheckpoint(); _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new Checkpoint { LastOpId = "0", WriteCheckpoint = "2", }, + Checkpoint = new Checkpoint { LastOpId = 0, WriteCheckpoint = 2, }, }); Assert.False(checkpoint.HasSynced); _syncService.PushLine(MockDataFactory.CheckpointComplete("0")); @@ -367,7 +364,7 @@ public async Task CheckpointRequests_ThrowsOnDisconnectButCanConnectAgain() await _db.Connect(new TestConnector(), new() { CheckpointMode = new CheckpointMode.Requests() }); _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new Checkpoint { LastOpId = "0", WriteCheckpoint = "2", }, + Checkpoint = new Checkpoint { LastOpId = 0, WriteCheckpoint = 2, }, }); _syncService.PushLine(MockDataFactory.CheckpointComplete("0")); await checkpoint.WaitForSync(); @@ -515,10 +512,7 @@ public async Task CheckpointRequests_RequestThrowsIfCanceledWhileWaitingForSeed( }; // Destroy the connection with a bogus line: checkpoint requests are no longer ready. - _syncService.PushLine(new StreamingSyncCheckpoint - { - Checkpoint = new() { LastOpId = "invalid line", Buckets = [] } - }); + _syncService.PushLine("""{"checkpoint":{"last_op_id":"invalid line","buckets":[]}}"""); await TestUtils.WaitForAsync(() => _db.CurrentStatus.DataFlowStatus.DownloadError != null); using var cts = new CancellationTokenSource(); @@ -557,7 +551,7 @@ public async Task CheckpointRequests_WaitForSyncThrowsIfCanceledWhileWaiting() // Abandoning a wait doesn't invalidate the request, it can still be awaited again. _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new Checkpoint { LastOpId = "0", WriteCheckpoint = "2", }, + Checkpoint = new Checkpoint { LastOpId = 0, WriteCheckpoint = 2, }, }); _syncService.PushLine(MockDataFactory.CheckpointComplete("0")); @@ -589,7 +583,7 @@ public async Task CheckpointRequests_WaitForSyncCancellationIsPerCaller() _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new Checkpoint { LastOpId = "0", WriteCheckpoint = "2", }, + Checkpoint = new Checkpoint { LastOpId = 0, WriteCheckpoint = 2, }, }); _syncService.PushLine(MockDataFactory.CheckpointComplete("0")); diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs index 09c5effa..f40ad126 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs @@ -41,6 +41,21 @@ public async Task DisposeAsync() """{ "checkpoint_complete":{ "last_op_id":"2"} }""" ]; + /// + /// The status published on the database must be current by the time Connect returns, so that a + /// caller reading CurrentStatus - or subscribing to OnStatusChanged - right after connecting + /// doesn't observe a status from before the connection settled. + /// + [Fact(Timeout = 15000)] + public async Task ConnectPublishesStatusBeforeReturning() + { + await db.Init(); + await db.Connect(new TestConnector()); + + Assert.True(db.CurrentStatus.Connected); + Assert.False(db.CurrentStatus.Connecting); + } + [Fact] public async Task SyncCreateOperationTest() { diff --git a/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs index 41843828..507a173c 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs @@ -53,7 +53,7 @@ public async Task DisablingCoreExtensionPreventsPowerSyncFromLoading() public async Task LoadsCustomPowerSyncExtensionFromOverriddenPath() { var dbName = $"MDSQLiteAdapter-{Guid.NewGuid():N}.db"; - var sourcePath = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); + var sourcePath = PowerSyncDesktopPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); var customPath = Path.Combine( Path.GetTempPath(), $"powersync-ext-copy-{Guid.NewGuid():N}{Path.GetExtension(sourcePath)}" diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs b/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs index 54c9b80e..8d897fc7 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs @@ -120,10 +120,11 @@ public static async Task NextStatus(PowerSyncDatabase db) { var tcs = new TaskCompletionSource(); var cts = new CancellationTokenSource(); + var listener = db.Events.OnStatusChanged.ListenAsync(cts.Token); _ = Task.Run(async () => { - await foreach (var update in db.Events.OnStatusChanged.ListenAsync(cts.Token)) + await foreach (var update in listener) { tcs.TrySetResult(update.Status); cts?.Cancel(); @@ -142,7 +143,7 @@ public static StreamingSyncCheckpoint Checkpoint(long lastOpId, List -/// Execute with `dotnet run --project Tools/Setup` -/// -public class PowerSyncSetup -{ - private const string VERSION = "0.5.3"; - - private const string GITHUB_BASE_URL = $"https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v{VERSION}"; - - private readonly HttpClient _httpClient; - private readonly string _basePath; - - public PowerSyncSetup() - { - _httpClient = new HttpClient(); - _basePath = Path.Combine(AppContext.BaseDirectory, "../../../../..", "PowerSync"); - } - - public async Task RunSetup() - { - try - { - await SetupDesktop(); - await SetupMauiIos(); - await SetupMauiAndroid(); - await SetupMauiMacCatalyst(); - } - finally - { - _httpClient?.Dispose(); - } - } - - public async Task SetupDesktop() - { - Console.WriteLine("Setting up Desktop libraries..."); - - var runtimeConfigs = GetDesktopRuntimeConfigs(); - var commonPath = Path.Combine(_basePath, "PowerSync.Common"); - - foreach (var config in runtimeConfigs) - { - await ProcessDesktopRuntime(commonPath, config); - } - } - - private static Dictionary GetDesktopRuntimeConfigs() - { - return new Dictionary - { - { "osx-x64", new RuntimeConfig("libpowersync_x64.macos.dylib", "libpowersync.dylib") }, - { "osx-arm64", new RuntimeConfig("libpowersync_aarch64.macos.dylib", "libpowersync.dylib") }, - { "linux-x64", new RuntimeConfig("libpowersync_x64.linux.so", "libpowersync.so") }, - { "linux-arm64", new RuntimeConfig("libpowersync_aarch64.linux.so", "libpowersync.so") }, - { "win-x64", new RuntimeConfig("powersync_x64.dll", "powersync.dll") }, - { "win-arm64", new RuntimeConfig("powersync_aarch64.dll", "powersync.dll") } - }; - } - - private async Task ProcessDesktopRuntime(string basePath, KeyValuePair runtimeConfig) - { - var (rid, config) = runtimeConfig; - var nativeDir = Path.Combine(basePath, "runtimes", rid, "native"); - - try - { - Directory.CreateDirectory(nativeDir); - - var downloadPath = Path.Combine(nativeDir, config.OriginalFileName); - var finalPath = Path.Combine(nativeDir, config.FinalFileName); - - var downloadUrl = $"{GITHUB_BASE_URL}/{config.OriginalFileName}"; - - await DownloadFile(downloadUrl, downloadPath); - File.Move(downloadPath, finalPath, overwrite: true); - - Console.WriteLine($"✓ {rid}: {config.OriginalFileName} → {config.FinalFileName}"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"✗ Failed to process {rid}: {ex.Message}"); - } - } - - public async Task SetupMauiIos() - { - Console.WriteLine("Setting up MAUI iOS libraries..."); - - var nativeDir = Path.Combine(_basePath, "PowerSync.Maui", "Platforms", "iOS", "NativeLibs"); - var config = new ArchiveConfig( - "powersync-sqlite-core.xcframework.zip", - "powersync-sqlite-core.xcframework" - ); - - await ProcessArchiveDownload(nativeDir, config, GITHUB_BASE_URL); - } - - public async Task SetupMauiAndroid() - { - Console.WriteLine("Setting up MAUI Android libraries..."); - - var nativeDir = Path.Combine(_basePath, "PowerSync.Maui", "Platforms", "Android", "jniLibs"); - - try - { - Directory.CreateDirectory(nativeDir); - - await Task.WhenAll( - DownloadAndroidLibrary("libpowersync_aarch64.android.so ", nativeDir, "arm64-v8a"), - DownloadAndroidLibrary("libpowersync_armv7.android.so ", nativeDir, "armeabi-v7a"), - DownloadAndroidLibrary("libpowersync_x86.android.so ", nativeDir, "x86"), - DownloadAndroidLibrary("libpowersync_x64.android.so ", nativeDir, "x86_64") - ); - - Console.WriteLine($"✓ Android: Downloaded native libraries"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"✗ Failed to setup Android: {ex.Message}"); - } - } - - private async Task DownloadAndroidLibrary(string filename, string jniLibsDir, string arch) - { - var targetDir = Path.Combine(jniLibsDir, arch); - Directory.CreateDirectory(targetDir); - var targetFile = Path.Combine(targetDir, "libpowersync.so"); - await DownloadFile($"{GITHUB_BASE_URL}/{filename}", targetFile); - } - - public async Task SetupMauiMacCatalyst() - { - Console.WriteLine("Setting up MAUI MacCatalyst libraries..."); - - var nativeDir = Path.Combine(_basePath, "PowerSync.Maui", "Platforms", "MacCatalyst", "NativeLibs"); - var config = new ArchiveConfig( - "powersync-sqlite-core.xcframework.zip", - "powersync-sqlite-core.xcframework" - ); - - await ProcessArchiveDownload(nativeDir, config, GITHUB_BASE_URL); - } - - private async Task ProcessArchiveDownload(string nativeDir, ArchiveConfig config, string baseUrl) - { - try - { - Directory.CreateDirectory(nativeDir); - - var downloadPath = Path.Combine(nativeDir, config.ArchiveFileName); - var extractedPath = Path.Combine(nativeDir, config.ExtractedName); - var downloadUrl = $"{baseUrl}/{config.ArchiveFileName}"; - - await DownloadFile(downloadUrl, downloadPath); - - // Clean up existing extraction - if (Directory.Exists(extractedPath)) - Directory.Delete(extractedPath, recursive: true); - - ExtractZipPreservingSymlinks(downloadPath, nativeDir); - File.Delete(downloadPath); - - Console.WriteLine($"✓ Extracted {config.ArchiveFileName} → {config.ExtractedName}"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"✗ Failed to process archive: {ex.Message}"); - } - } - - private static void ExtractZipPreservingSymlinks(string zipPath, string destDir) - { - // ZipFile.ExtractToDirectory does not preserve symlinks, which breaks - // macOS/Catalyst .xcframework bundles. Use `unzip` on Unix instead. - if (!OperatingSystem.IsWindows()) - { - var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = "unzip", - ArgumentList = { "-o", zipPath, "-d", destDir }, - RedirectStandardOutput = true, - RedirectStandardError = true, - })!; - proc.WaitForExit(); - if (proc.ExitCode != 0) - throw new Exception($"unzip exited with code {proc.ExitCode}: {proc.StandardError.ReadToEnd()}"); - } - else - { - ZipFile.ExtractToDirectory(zipPath, destDir); - } - } - - private async Task DownloadFile(string url, string outputPath) - { - Console.WriteLine($"📥 Downloading: {Path.GetFileName(outputPath)}"); - - using var response = await _httpClient.GetAsync(url); - - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"Download failed: {response.StatusCode} {response.ReasonPhrase}"); - } - - await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None); - await response.Content.CopyToAsync(fileStream); - } - - private static void CleanupPaths(params string[] paths) - { - foreach (var path in paths) - { - try - { - if (File.Exists(path)) - File.Delete(path); - else if (Directory.Exists(path)) - Directory.Delete(path, recursive: true); - } - catch (Exception ex) - { - Console.WriteLine($"⚠️ Cleanup warning for {path}: {ex.Message}"); - } - } - } - - private record RuntimeConfig(string OriginalFileName, string FinalFileName); - private record ArchiveConfig(string ArchiveFileName, string ExtractedName); -} - -public class Program -{ - static async Task Main(string[] args) - { - var setup = new PowerSyncSetup(); - await setup.RunSetup(); - } -} +using System.IO.Compression; +using System.Xml; +using System.Xml.Linq; + +namespace Tools.Setup; + +/// +/// Downloads the powersync-sqlite-core native libraries into PowerSync.Common/runtimes, +/// laid out using NuGet's RID conventions so a single package can serve every target: +/// +/// runtimes/{rid}/native/... desktop and Android, resolved by RID +/// runtimes/ios/native/*.xcframework.zip +/// runtimes/maccatalyst/native/*.xcframework.zip +/// +/// The Apple slices stay zipped on purpose. NuGet pack/extract and ordinary file copies +/// drop symlinks, which corrupts the versioned MacCatalyst framework bundle and makes +/// codesign fail with "bundle format is ambiguous". The Apple SDK unzips these itself +/// and embeds the framework into App.app/Frameworks. +/// +/// The CI "sign" step is an ordinary extract/rebuild too: `dotnet sign` unconditionally +/// unzips every nested .zip it finds inside a .nupkg (even without --recurse-containers) +/// and rewrites it from the extracted files, which drops symlinks *and* the executable +/// bit on every entry - the exact corruption this class avoids at pack time. The sign +/// steps in dev-packages.yml/release.yml pass --file-list Tools/Setup/sign-file-list.txt +/// to exclude the two xcframework.zip files from that rebuild so they reach NuGet byte +/// for byte. +/// +/// Execute with `dotnet run --project Tools/Setup` +/// +public class PowerSyncSetup +{ + private const string VERSION = "0.5.3"; + + private const string GITHUB_BASE_URL = $"https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v{VERSION}"; + + private const string XCFRAMEWORK_NAME = "powersync-sqlite-core.xcframework"; + + private readonly HttpClient _httpClient; + private readonly string _commonPath; + + public PowerSyncSetup() + { + _httpClient = new HttpClient(); + _commonPath = Path.Combine(AppContext.BaseDirectory, "../../../../..", "PowerSync", "PowerSync.Common"); + } + + public async Task RunSetup() + { + try + { + await SetupRuntimes(); + await SetupApple(); + } + finally + { + _httpClient?.Dispose(); + } + } + + /// + /// Every target whose native library is a single downloadable file: one entry per RID. + /// + public async Task SetupRuntimes() + { + Console.WriteLine("Setting up native libraries..."); + + foreach (var config in GetRuntimeConfigs()) + { + await ProcessRuntime(config); + } + } + + private static Dictionary GetRuntimeConfigs() + { + return new Dictionary + { + // Desktop + { "osx-x64", new RuntimeConfig("libpowersync_x64.macos.dylib", "libpowersync.dylib") }, + { "osx-arm64", new RuntimeConfig("libpowersync_aarch64.macos.dylib", "libpowersync.dylib") }, + { "linux-x64", new RuntimeConfig("libpowersync_x64.linux.so", "libpowersync.so") }, + { "linux-arm64", new RuntimeConfig("libpowersync_aarch64.linux.so", "libpowersync.so") }, + { "win-x64", new RuntimeConfig("powersync_x64.dll", "powersync.dll") }, + { "win-arm64", new RuntimeConfig("powersync_aarch64.dll", "powersync.dll") }, + + // Android. These reach the APK's ABI directories via AndroidNativeLibrary items + // in PowerSync.Common.csproj (ProjectReference) and NuGet RID resolution (PackageReference). + { "android-arm64", new RuntimeConfig("libpowersync_aarch64.android.so", "libpowersync.so") }, + { "android-x64", new RuntimeConfig("libpowersync_x64.android.so", "libpowersync.so") }, + { "android-arm", new RuntimeConfig("libpowersync_armv7.android.so", "libpowersync.so") }, + { "android-x86", new RuntimeConfig("libpowersync_x86.android.so", "libpowersync.so") }, + }; + } + + private async Task ProcessRuntime(KeyValuePair runtimeConfig) + { + var (rid, config) = runtimeConfig; + var nativeDir = Path.Combine(_commonPath, "runtimes", rid, "native"); + + try + { + Directory.CreateDirectory(nativeDir); + + var downloadPath = Path.Combine(nativeDir, config.OriginalFileName); + var finalPath = Path.Combine(nativeDir, config.FinalFileName); + + var downloadUrl = $"{GITHUB_BASE_URL}/{config.OriginalFileName}"; + + await DownloadFile(downloadUrl, downloadPath); + File.Move(downloadPath, finalPath, overwrite: true); + + Console.WriteLine($"✓ {rid}: {config.OriginalFileName} → {config.FinalFileName}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"✗ Failed to process {rid}: {ex.Message}"); + throw; + } + } + + /// + /// Apple ships one xcframework covering every Apple platform, so it needs its own path: + /// download once, then write a per-platform copy carrying only the slices that platform + /// needs. The published archive is ~135MB — it includes tvOS, watchOS and macOS slices we + /// do not target, plus a dSYM per slice — which trims down to a few MB. + /// + public async Task SetupApple() + { + Console.WriteLine("Setting up Apple libraries..."); + + var tempZip = Path.Combine(Path.GetTempPath(), $"{XCFRAMEWORK_NAME}.zip"); + + try + { + await DownloadFile($"{GITHUB_BASE_URL}/{XCFRAMEWORK_NAME}.zip", tempZip); + + WriteTrimmedXcframework(tempZip, "ios", ["ios-arm64", "ios-arm64_x86_64-simulator"]); + WriteTrimmedXcframework(tempZip, "maccatalyst", ["ios-arm64_x86_64-maccatalyst"]); + } + catch (Exception ex) + { + Console.Error.WriteLine($"✗ Failed to process Apple libraries: {ex.Message}"); + throw; + } + finally + { + if (File.Exists(tempZip)) File.Delete(tempZip); + } + } + + private void WriteTrimmedXcframework(string sourceZip, string rid, string[] keepSlices) + { + var nativeDir = Path.Combine(_commonPath, "runtimes", rid, "native"); + Directory.CreateDirectory(nativeDir); + + var targetZip = Path.Combine(nativeDir, $"{XCFRAMEWORK_NAME}.zip"); + if (File.Exists(targetZip)) File.Delete(targetZip); + + using var source = ZipFile.OpenRead(sourceZip); + using var targetStream = new FileStream(targetZip, FileMode.Create, FileAccess.Write, FileShare.None); + using var target = new ZipArchive(targetStream, ZipArchiveMode.Create); + + var infoPlistPath = $"{XCFRAMEWORK_NAME}/Info.plist"; + + foreach (var entry in source.Entries) + { + // Debug symbols are the bulk of the download and are not needed to build against. + if (entry.FullName.Contains("/dSYMs/")) continue; + + var slice = GetSliceName(entry.FullName); + if (slice != null && !keepSlices.Contains(slice)) continue; + + // Copying the raw bytes plus ExternalAttributes is what preserves symlinks: they + // are stored as entries whose content is the link target and whose unix mode bits + // live in ExternalAttributes. Extracting and re-zipping would lose them. + var copy = target.CreateEntry(entry.FullName, CompressionLevel.Optimal); + copy.ExternalAttributes = entry.ExternalAttributes; + copy.LastWriteTime = entry.LastWriteTime; + + using var output = copy.Open(); + + if (entry.FullName == infoPlistPath) + { + using var plistStream = entry.Open(); + using var trimmed = TrimInfoPlist(plistStream, keepSlices); + trimmed.CopyTo(output); + } + else + { + using var input = entry.Open(); + input.CopyTo(output); + } + } + + Console.WriteLine($"✓ {rid}: {XCFRAMEWORK_NAME}.zip ({string.Join(", ", keepSlices)})"); + } + + /// + /// Returns the xcframework slice a zip entry belongs to, or null for entries that sit + /// outside a slice (the root Info.plist, LICENSE, README). + /// + private static string? GetSliceName(string entryPath) + { + var parts = entryPath.Split('/'); + if (parts.Length < 3 || parts[0] != XCFRAMEWORK_NAME) return null; + return parts[1]; + } + + /// + /// Drops the removed slices from the xcframework's AvailableLibraries list, so the Apple + /// SDK does not go looking for slices that are no longer present in the archive. + /// + private static MemoryStream TrimInfoPlist(Stream plistStream, string[] keepSlices) + { + // XmlResolver is null so the Apple DTD named by the DOCTYPE is never fetched, while + // DtdProcessing.Parse keeps the DOCTYPE itself in the output. + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse, XmlResolver = null }; + using var reader = XmlReader.Create(plistStream, settings); + var doc = XDocument.Load(reader); + + var librariesArray = doc.Root?.Element("dict") + ?.Elements("key") + .FirstOrDefault(k => k.Value == "AvailableLibraries") + ?.NextNode as XElement; + + librariesArray?.Elements("dict") + .Where(d => !keepSlices.Contains(PlistString(d, "LibraryIdentifier"))) + .Remove(); + + var output = new MemoryStream(); + doc.Save(output); + output.Position = 0; + return output; + } + + private static string? PlistString(XElement dict, string key) + { + var keyElement = dict.Elements("key").FirstOrDefault(k => k.Value == key); + return (keyElement?.NextNode as XElement)?.Value; + } + + private async Task DownloadFile(string url, string outputPath) + { + Console.WriteLine($"📥 Downloading: {Path.GetFileName(outputPath)}"); + + using var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Download failed: {response.StatusCode} {response.ReasonPhrase}"); + } + + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None); + await response.Content.CopyToAsync(fileStream); + } + + private record RuntimeConfig(string OriginalFileName, string FinalFileName); +} + +public class Program +{ + static async Task Main() + { + var setup = new PowerSyncSetup(); + await setup.RunSetup(); + } +} + diff --git a/Tools/Setup/Setup.csproj b/Tools/Setup/Setup.csproj index c5c3bf8e..858fcbdd 100644 --- a/Tools/Setup/Setup.csproj +++ b/Tools/Setup/Setup.csproj @@ -1,7 +1,8 @@  - Exe net10.0 + enable + enable diff --git a/Tools/Setup/sign-file-list.txt b/Tools/Setup/sign-file-list.txt new file mode 100644 index 00000000..8ec5fd27 --- /dev/null +++ b/Tools/Setup/sign-file-list.txt @@ -0,0 +1,3 @@ +**/* +!runtimes/ios/native/*.xcframework.zip +!runtimes/maccatalyst/native/*.xcframework.zip diff --git a/demos/MAUITodo/Data/PowerSyncData.cs b/demos/MAUITodo/Data/PowerSyncData.cs index d5e2d600..9176ee71 100644 --- a/demos/MAUITodo/Data/PowerSyncData.cs +++ b/demos/MAUITodo/Data/PowerSyncData.cs @@ -10,7 +10,6 @@ using PowerSync.Common.Client.Sync; using PowerSync.Common.Client.Sync.Stream; using PowerSync.Common.MDSQLite; -using PowerSync.Maui.SQLite; namespace MAUITodo.Data; @@ -31,7 +30,7 @@ public PowerSyncData() var logger = loggerFactory.CreateLogger("PowerSyncLogger"); var dbPath = Path.Combine(FileSystem.AppDataDirectory, "example.db"); - var factory = new MAUISQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions() + var factory = new MDSQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions() { DbFilename = dbPath }); diff --git a/demos/MAUITodo/MAUITodo.csproj b/demos/MAUITodo/MAUITodo.csproj index 9f38150b..9c5cd30d 100644 --- a/demos/MAUITodo/MAUITodo.csproj +++ b/demos/MAUITodo/MAUITodo.csproj @@ -1,10 +1,9 @@  - com.companyname.todo net9.0-android;net10.0-android - $(TargetFrameworks);net9.0-windows;net10.0-windows + $(TargetFrameworks);net9.0-windows10.0.19041.0;net10.0-windows10.0.19041.0 $(TargetFrameworks);net9.0-ios;net9.0-maccatalyst;net10.0-ios;net10.0-maccatalyst Exe @@ -64,6 +63,5 @@ - diff --git a/docs/README.md b/docs/README.md index 74e25b9b..9c3cc73e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,4 +27,4 @@ dotnet docfx docfx.json --serve ## Notes -Projects are built with only the `net8.0` target present (see the `properties` field in `docfx.json`) so that the MAUI workloads and the native `powersync-sqlite-core` binaries are not required to build the docs. +Projects are built with only the `net10.0` target present (see the `properties` field in `docfx.json`) so that the MAUI workloads and the native `powersync-sqlite-core` binaries are not required to build the docs. diff --git a/docs/docfx.json b/docs/docfx.json index 4c7aee77..96d0d6d6 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -5,17 +5,13 @@ "src": [ { "src": "../PowerSync", - "files": [ - "PowerSync.Common/PowerSync.Common.csproj", - "PowerSync.Maui/PowerSync.Maui.csproj" - ] + "files": ["PowerSync.Common/PowerSync.Common.csproj"] } ], "dest": "api", - "filter": "filter.yml", "properties": { - "TargetFramework": "net8.0", - "TargetFrameworks": "net8.0" + "TargetFramework": "net10.0", + "TargetFrameworks": "net10.0" }, "namespaceLayout": "nested", "memberLayout": "separatePages", @@ -27,13 +23,7 @@ "content": [ { "files": ["**/*.{md,yml}"], - "exclude": [ - "_site/**", - "obj/**", - "template/**", - "README.md", - "filter.yml" - ] + "exclude": ["_site/**", "obj/**", "template/**", "README.md"] } ], "resource": [ diff --git a/docs/filter.yml b/docs/filter.yml deleted file mode 100644 index 257f884c..00000000 --- a/docs/filter.yml +++ /dev/null @@ -1,5 +0,0 @@ -apiRules: - # Ignore the `build/ApiDefinition.cs` file in PowerSync.Maui (required to build for Apple targets) - - exclude: - uidRegex: ^PowerSync\.Maui\.build$ - type: Namespace diff --git a/docs/toc.yml b/docs/toc.yml index 8c52d26d..5528c4ab 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,2 +1,2 @@ - name: API Reference - uid: PowerSync + uid: PowerSync.Common diff --git a/root.sln b/root.sln index 072d04eb..1519e87c 100644 --- a/root.sln +++ b/root.sln @@ -21,8 +21,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MAUITodo", "demos\MAUITodo\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WPF", "demos\WPF\WPF.csproj", "{AF297026-0BEA-4B8E-97C9-6540C6D52B36}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerSync.Maui", "PowerSync\PowerSync.Maui\PowerSync.Maui.csproj", "{A4A91B9F-0C86-41CB-BEF0-C002819C43BE}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerSync.Common.IntegrationTests", "Tests\PowerSync\PowerSync.Common.IntegrationTests\PowerSync.Common.IntegrationTests.csproj", "{EB81D453-777D-40B5-A504-4144906ADBF4}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerSync.Common.PerformanceTests", "Tests\PowerSync\PowerSync.Common.PerformanceTests\PowerSync.Common.PerformanceTests.csproj", "{B2BA37D6-0549-455D-9520-B3D21C7876D0}" @@ -97,18 +95,6 @@ Global {AF297026-0BEA-4B8E-97C9-6540C6D52B36}.Release|x64.Build.0 = Release|Any CPU {AF297026-0BEA-4B8E-97C9-6540C6D52B36}.Release|x86.ActiveCfg = Release|Any CPU {AF297026-0BEA-4B8E-97C9-6540C6D52B36}.Release|x86.Build.0 = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|x64.ActiveCfg = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|x64.Build.0 = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|x86.ActiveCfg = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|x86.Build.0 = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|Any CPU.Build.0 = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|x64.ActiveCfg = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|x64.Build.0 = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|x86.ActiveCfg = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|x86.Build.0 = Release|Any CPU {EB81D453-777D-40B5-A504-4144906ADBF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EB81D453-777D-40B5-A504-4144906ADBF4}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB81D453-777D-40B5-A504-4144906ADBF4}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -144,7 +130,6 @@ Global {D7FDA714-D29F-4D85-B3F2-74C6810A36F9} = {9144195A-C68F-4B1E-A574-474EDD424D6C} {B8B2A9E2-FEC9-495B-B03E-23078E7B651D} = {9144195A-C68F-4B1E-A574-474EDD424D6C} {AF297026-0BEA-4B8E-97C9-6540C6D52B36} = {9144195A-C68F-4B1E-A574-474EDD424D6C} - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE} = {B1D87BA9-8812-4EFA-BBBE-1FF1EEEB5433} {EB81D453-777D-40B5-A504-4144906ADBF4} = {C784FBE4-CC1E-4A0A-AE8E-6B818DD3724D} {B2BA37D6-0549-455D-9520-B3D21C7876D0} = {C784FBE4-CC1E-4A0A-AE8E-6B818DD3724D} EndGlobalSection From 545214a3ed6857518c55d42f82ac6d2ac734e98e Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 23 Sep 2026 12:08:49 +0200 Subject: [PATCH 10/18] Add Tools/Setup to solution, update comment --- .../Sync/Stream/StreamingSyncImplementation.cs | 6 +++--- root.sln | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs index 443d2e7d..10139964 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs @@ -1261,13 +1261,13 @@ protected void UpdateSyncStatus(SyncStatusOptions options, UpdateSyncStatusOptio } }); - // TODO: Always false because Equals is reference equality. - // A SyncStatus.IsEqual method exists, but is very inefficient. + // TODO: Always false because SyncStatus.Equals checks reference equality. + // There is a SyncStatus.IsEqual method, but it's slow enough that + // it's probably cheaper to just update the status unconditionally. if (!_syncStatus.Equals(updatedStatus)) { _syncStatus = updatedStatus; - // Suppress CA1873 (expensive arguments to logging function) if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("[Sync status changed]: {message}", updatedStatus.ToJSON()); diff --git a/root.sln b/root.sln index 1519e87c..91bc3131 100644 --- a/root.sln +++ b/root.sln @@ -25,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerSync.Common.Integratio EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerSync.Common.PerformanceTests", "Tests\PowerSync\PowerSync.Common.PerformanceTests\PowerSync.Common.PerformanceTests.csproj", "{B2BA37D6-0549-455D-9520-B3D21C7876D0}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Setup", "Tools\Setup\Setup.csproj", "{1323175D-52B3-41C4-8BA9-C5887E393942}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -119,6 +123,18 @@ Global {B2BA37D6-0549-455D-9520-B3D21C7876D0}.Release|x64.Build.0 = Release|Any CPU {B2BA37D6-0549-455D-9520-B3D21C7876D0}.Release|x86.ActiveCfg = Release|Any CPU {B2BA37D6-0549-455D-9520-B3D21C7876D0}.Release|x86.Build.0 = Release|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Debug|x64.ActiveCfg = Debug|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Debug|x64.Build.0 = Debug|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Debug|x86.ActiveCfg = Debug|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Debug|x86.Build.0 = Debug|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Release|Any CPU.Build.0 = Release|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Release|x64.ActiveCfg = Release|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Release|x64.Build.0 = Release|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Release|x86.ActiveCfg = Release|Any CPU + {1323175D-52B3-41C4-8BA9-C5887E393942}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -132,5 +148,6 @@ Global {AF297026-0BEA-4B8E-97C9-6540C6D52B36} = {9144195A-C68F-4B1E-A574-474EDD424D6C} {EB81D453-777D-40B5-A504-4144906ADBF4} = {C784FBE4-CC1E-4A0A-AE8E-6B818DD3724D} {B2BA37D6-0549-455D-9520-B3D21C7876D0} = {C784FBE4-CC1E-4A0A-AE8E-6B818DD3724D} + {1323175D-52B3-41C4-8BA9-C5887E393942} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} EndGlobalSection EndGlobal From 4c46fbc1c20d0641896713fac2ab10d1bf5fa784 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 29 Sep 2026 10:21:28 +0200 Subject: [PATCH 11/18] scaffold classes --- .../PowerSync.Common/DB/Schema/RawTable.cs | 88 +++++++++++++++++++ .../PowerSync.Common/DB/Schema/Schema.cs | 21 +++++ PowerSync/PowerSync.Common/DB/Schema/Table.cs | 7 +- 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 PowerSync/PowerSync.Common/DB/Schema/RawTable.cs diff --git a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs new file mode 100644 index 00000000..91e57c09 --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs @@ -0,0 +1,88 @@ +using Newtonsoft.Json; + +namespace PowerSync.Common.DB.Schema; + +public class RawTable(string name, PendingStatement put, PendingStatement delete) +{ + [JsonProperty("name")] + public string Name { get; private set; } = name; + + [JsonProperty("put")] + public PendingStatement Put { get; private set; } = put; + + [JsonProperty("delete")] + public PendingStatement Delete { get; private set; } = delete; + + public void Validate() + { + // TODO + } +} + +public class PendingStatement +{ + [JsonProperty("sql")] + public string SQL { get; set; } + + [JsonProperty("params")] + public IReadOnlyList Parameters { get; set; } +} + +[JsonConverter(PendingStatementParameterJsonConverter)] +public abstract record PendingStatementParameter +{ + private PendingStatementParameter() { } + + /// + /// Resolves to the ID of the affected row. + /// + public static readonly PendingStatementParameter Id = new(); + + /// + /// Resolves to the value of a column in the added row. + /// + /// This is only available for ; in + /// statements, only the can be used as a value. + /// + public static PendingStatementParameter Column(string name) => new ValueColumn(name); + + /// + /// Resolves to a JSON object containing all columns from the synced row that haven't been + /// matched by a value in the same statement. + /// + public static readonly PendingStatementParameter Rest = new(); + + /// + public sealed record ValueColumn(string Name) : PendingStatementParameter; +} + +internal class PendingStatementParameterJsonConverter : JsonConverter +{ + // We never need to deserialize pending stamements. + public override PendingStatementParameter ReadJson() + { + throw new NotImplementedException("Deserializing PendingStatementParameter is not supported."); + } + + public override void WriteJson(JsonWriter writer, PendingStatementParameter value, JsonSerializer _serializer) + { + if (value == PendingStatementParameter.Id) + { + writer.WriteValue("Id"); + } + else if (value is PendingStatementParameter.ValueColumn column) + { + JObject json = new JObject(new JProperty("Column", column.Name)); + writer.WriteValue(json); + } + else if (value is PendingStatementParameter.Rest) + { + writer.WriteValue("Rest"); + } + else + { + throw new InvalidOperationException("Incorrect type for given PendingStatementParameter."); + } + } +} + diff --git a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs index dc8d7b0b..2dd31956 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs @@ -8,17 +8,34 @@ namespace PowerSync.Common.DB.Schema; public class Schema { private readonly List _tables; + private readonly List _rawTables; public IReadOnlyList
Tables => _tables; + public IReadOnlyList RawTables => _rawTables; public Schema(params Table[] tables) { _tables = [.. tables]; + _rawTables = []; + } + + public Schema(params RawTable[] rawTables) + { + _tables = []; + _rawTables = [.. rawTables]; + } + + public Schema(List
tables, List rawTables) + { + _tables = tables; + _rawTables = rawTables; } public Schema(params Type[] types) { _tables = []; + _rawTables = []; + // TODO: Should there be a mechanism for creating raw tables from types? foreach (Type type in types) { var parser = new AttributeParser(type); @@ -33,6 +50,10 @@ public void Validate() { table.Validate(); } + foreach (var rawTable in _rawTables) + { + rawTable.Validate(); + } } } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index b70ebd9c..d4ce2e00 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -1,10 +1,11 @@ -namespace PowerSync.Common.DB.Schema; - using System.Text.RegularExpressions; using Newtonsoft.Json; using PowerSync.Common.DB.Schema.Attributes; +using PowerSync.Common.Utils.Converters; + +namespace PowerSync.Common.DB.Schema; public class TableOptions( Dictionary>? indexes = null, @@ -238,7 +239,7 @@ public void Validate() /// Serializes a into the JSON format expected by the /// `powersync_replace_schema` SQLite function. /// -public class TableJsonConverter : JsonConverter
+internal class TableJsonConverter : JsonConverter
{ public override bool CanRead => false; From 824cee544927a139098f53eacc301b23d13ae3c0 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 30 Sep 2026 12:21:11 +0200 Subject: [PATCH 12/18] temp commit --- .../Client/PowerSyncDatabase.cs | 1 + .../Stream/StreamingSyncImplementation.cs | 3 ++ .../PowerSync.Common/DB/Schema/BaseTable.cs | 7 +++ .../PowerSync.Common/DB/Schema/RawTable.cs | 27 ++++++----- .../PowerSync.Common/DB/Schema/Schema.cs | 47 ++++++++++--------- PowerSync/PowerSync.Common/DB/Schema/Table.cs | 6 +-- PowerSync/PowerSync.Common/README.md | 4 -- 7 files changed, 55 insertions(+), 40 deletions(-) create mode 100644 PowerSync/PowerSync.Common/DB/Schema/BaseTable.cs diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index 56cd4131..c73ff2e8 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -250,6 +250,7 @@ public PowerSyncDatabase(PowerSyncDatabaseOptions options) : null, RetryDelayMs = options.RetryDelayMs, Subscriptions = options.Subscriptions, + Schema = schema, CrudUploadThrottleMs = options.CrudUploadThrottleMs, TimeProvider = timeProvider, Logger = Logger, diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs index 10139964..4ee41814 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs @@ -62,6 +62,8 @@ public class StreamingSyncImplementationOptions : AdditionalConnectionOptions public ILogger? Logger { get; init; } + public Schema Schema { get; init; } = null!; + /// /// Called synchronously whenever the sync status changes, before the change is published on /// . It runs while the status lock is held, so it @@ -927,6 +929,7 @@ async Task HandleInstruction(NonInterruptingInstruction instruction) var options = new { parameters = resolvedOptions.Params, + schema = resolvedOptions.Schema, active_streams = activeStreams, include_defaults = resolvedOptions.IncludeDefaultStreams, app_metadata = resolvedOptions.AppMetadata, diff --git a/PowerSync/PowerSync.Common/DB/Schema/BaseTable.cs b/PowerSync/PowerSync.Common/DB/Schema/BaseTable.cs new file mode 100644 index 00000000..577d34d3 --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/BaseTable.cs @@ -0,0 +1,7 @@ +namespace PowerSync.Common.DB.Schema; + +public abstract class BaseTable +{ + public abstract string Name { get; set; } + public abstract void Validate(); +} diff --git a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs index 91e57c09..6481c602 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs @@ -1,11 +1,12 @@ using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace PowerSync.Common.DB.Schema; -public class RawTable(string name, PendingStatement put, PendingStatement delete) +public class RawTable(string name, PendingStatement put, PendingStatement delete) : BaseTable { [JsonProperty("name")] - public string Name { get; private set; } = name; + public override string Name { get; set; } = name; [JsonProperty("put")] public PendingStatement Put { get; private set; } = put; @@ -13,23 +14,25 @@ public class RawTable(string name, PendingStatement put, PendingStatement delete [JsonProperty("delete")] public PendingStatement Delete { get; private set; } = delete; - public void Validate() + public override void Validate() + + { - // TODO + // We don't currently have any validation for raw tables } } -public class PendingStatement +public class PendingStatement(string sql, IReadOnlyList parameters) { [JsonProperty("sql")] - public string SQL { get; set; } + public string SQL { get; set; } = sql; [JsonProperty("params")] - public IReadOnlyList Parameters { get; set; } + public IReadOnlyList Parameters { get; set; } = parameters; } -[JsonConverter(PendingStatementParameterJsonConverter)] -public abstract record PendingStatementParameter +[JsonConverter(typeof(PendingStatementParameterJsonConverter))] +public record PendingStatementParameter { private PendingStatementParameter() { } @@ -59,9 +62,9 @@ public sealed record ValueColumn(string Name) : PendingStatementParameter; internal class PendingStatementParameterJsonConverter : JsonConverter { // We never need to deserialize pending stamements. - public override PendingStatementParameter ReadJson() + public override PendingStatementParameter ReadJson(JsonReader reader, Type objectType, PendingStatementParameter? existingValue, bool hasExistingValue, JsonSerializer serializer) { - throw new NotImplementedException("Deserializing PendingStatementParameter is not supported."); + throw new NotSupportedException("Deserializing PendingStatementParameter is not supported."); } public override void WriteJson(JsonWriter writer, PendingStatementParameter value, JsonSerializer _serializer) @@ -75,7 +78,7 @@ public override void WriteJson(JsonWriter writer, PendingStatementParameter valu JObject json = new JObject(new JProperty("Column", column.Name)); writer.WriteValue(json); } - else if (value is PendingStatementParameter.Rest) + else if (value == PendingStatementParameter.Rest) { writer.WriteValue("Rest"); } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs index 2dd31956..99e4f757 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs @@ -1,5 +1,7 @@ namespace PowerSync.Common.DB.Schema; +using System.Linq; + using Newtonsoft.Json; using PowerSync.Common.DB.Schema.Attributes; @@ -12,38 +14,41 @@ public class Schema public IReadOnlyList
Tables => _tables; public IReadOnlyList RawTables => _rawTables; + public IReadOnlyList AllTables => [.. _tables, .. _rawTables]; - public Schema(params Table[] tables) + public Schema(params BaseTable[] tables) { - _tables = [.. tables]; - _rawTables = []; + _tables = [.. tables.OfType
()]; + _rawTables = [.. tables.OfType()]; } - public Schema(params RawTable[] rawTables) + public Schema(params Type[] tables) { _tables = []; - _rawTables = [.. rawTables]; - } - - public Schema(List
tables, List rawTables) - { - _tables = tables; - _rawTables = rawTables; + _rawTables = []; + foreach (Type type in tables) + { + RegisterType(type); + } } - public Schema(params Type[] types) + public Schema(IReadOnlyList tables, IReadOnlyList rawTables) { _tables = []; - _rawTables = []; - // TODO: Should there be a mechanism for creating raw tables from types? - foreach (Type type in types) + _rawTables = [.. rawTables]; + foreach (Type type in tables) { - var parser = new AttributeParser(type); - parser.RegisterDapperTypeMap(); - _tables.Add(parser.ParseTable()); + RegisterType(type); } } + private void RegisterType(Type type) + { + var parser = new AttributeParser(type); + parser.RegisterDapperTypeMap(); + _tables.Add(parser.ParseTable()); + } + public void Validate() { foreach (var table in _tables) @@ -66,12 +71,12 @@ public class SchemaJsonConverter : JsonConverter public override bool CanRead => false; public override Schema ReadJson(JsonReader reader, Type objectType, Schema? existingValue, bool hasExistingValue, JsonSerializer serializer) - => throw new NotSupportedException("Deserializing a Schema from JSON is not supported."); + => throw new NotSupportedException("Deserializing Schema is not supported."); public override void WriteJson(JsonWriter writer, Schema? value, JsonSerializer serializer) { - if (value == null) throw new ArgumentNullException(nameof(value)); + ArgumentNullException.ThrowIfNull(value); - serializer.Serialize(writer, new { tables = value.Tables }); + serializer.Serialize(writer, new { tables = value.Tables, raw_tables = value.RawTables }); } } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index d4ce2e00..6e6fccf2 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -64,13 +64,13 @@ public class TrackPreviousOptions } [JsonConverter(typeof(TableJsonConverter))] -public class Table +public class Table : BaseTable { public static readonly Regex InvalidSQLCharacters = new Regex(@"[""'%,.#\s\[\]]", RegexOptions.Compiled); public const int MAX_AMOUNT_OF_COLUMNS = 1999; - public string Name { get; set; } + public override string Name { get; set; } public Dictionary Columns { get; set; } public TableOptions Options { get; set; } @@ -153,7 +153,7 @@ public Table(string name, Dictionary columns, TableOptions? Options = options ?? new TableOptions(); } - public void Validate() + public override void Validate() { if (string.IsNullOrWhiteSpace(Name)) { diff --git a/PowerSync/PowerSync.Common/README.md b/PowerSync/PowerSync.Common/README.md index 9526ecdf..5ae6324a 100644 --- a/PowerSync/PowerSync.Common/README.md +++ b/PowerSync/PowerSync.Common/README.md @@ -2,10 +2,6 @@ This package contains a .NET implementation of a PowerSync database connector and streaming sync bucket implementation. -## ⚠️ Project Status & Release Note - -This package is in beta and is considered ready for production use for tested use cases. See our feature status definitions [here](https://docs.powersync.com/resources/feature-status). - ## Installation This package is published on [NuGet](https://www.nuget.org/packages/PowerSync.Common). From 59bc0a91ea436dcb411729dd29547100a577a03a Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 1 Oct 2026 11:27:08 +0200 Subject: [PATCH 13/18] json converters, doc comments, better structuring, test --- .../Stream/StreamingSyncImplementation.cs | 1 + .../PowerSync.Common/DB/Schema/RawTable.cs | 135 ++++++++++++++++-- PowerSync/PowerSync.Common/DB/Schema/Table.cs | 23 +-- .../DB/RawTableTests.cs | 82 +++++++++++ .../Utils/DatabaseUtils.cs | 5 + 5 files changed, 225 insertions(+), 21 deletions(-) create mode 100644 Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs index 4ee41814..ec854e0b 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs @@ -12,6 +12,7 @@ namespace PowerSync.Common.Client.Sync.Stream; using PowerSync.Common.Client.Sync.Bucket; using PowerSync.Common.DB.Crud; +using PowerSync.Common.DB.Schema; using PowerSync.Common.Utils; using PowerSync.Common.Utils.Converters; diff --git a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs index 6481c602..e0199ce6 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs @@ -3,22 +3,83 @@ namespace PowerSync.Common.DB.Schema; -public class RawTable(string name, PendingStatement put, PendingStatement delete) : BaseTable +[JsonConverter(typeof(RawTableJsonConverter))] +public class RawTable : BaseTable { - [JsonProperty("name")] - public override string Name { get; set; } = name; + public override string Name { get; set; } + public RawTableSchema? Schema { get; set; } + public PendingStatement? Put { get; set; } + public PendingStatement? Delete { get; set; } + public string? Clear { get; set; } - [JsonProperty("put")] - public PendingStatement Put { get; private set; } = put; + public RawTable(string name, RawTableSchema schema, string? clear = null) + { + Name = name; + Schema = schema; + Clear = clear; + } - [JsonProperty("delete")] - public PendingStatement Delete { get; private set; } = delete; + public RawTable(string name, PendingStatement put, PendingStatement delete, string? clear = null) + { + Name = name; + Put = put; + Delete = delete; + Clear = clear; + } public override void Validate() + { + if (Schema is not null) + { + Schema.Validate(); + } + else + { + if (Put is null || Delete is null) + { + throw new InvalidOperationException("Raw tables without a schema need to provide put and delete statements."); + } + } + } +} +/// +/// The schema of a [RawTable] in the local database. +/// +/// This information is optional when declaring raw tables. However, providing it allows the sync +/// client to infer [RawTable.put] and [RawTable.delete] statements automatically. +/// +public class RawTableSchema +{ + /// + /// The actual name of the raw table in the local schema. + /// + /// While specifies the name of the synced tables to match, + /// this specifies the name of the table in the local SQLite database. This is + /// used to infer statements for the sync client. It can also be used to auto-generate + /// triggers forwarding writes on raw tables into the CRUD upload queue. + /// + /// When set to null, defaults to . + /// + public string? TableName { get; set; } + + /// + /// An optional filter of columns that should be synced. + /// + /// By default, all columns in a raw table are considered to be synced. If a filter is specified, + /// PowerSync treats unmatched columns as local-only and will not attempt to sync them. + /// + public List? SyncedColumns { get; set; } + + /// + /// Common options affecting how the `powersync_create_raw_table_crud_trigger` SQL function + /// generates triggers. + /// + public TableOptions Options { get; set; } = new(); + public void Validate() { - // We don't currently have any validation for raw tables + Options.Validate(); } } @@ -59,24 +120,74 @@ private PendingStatementParameter() { } public sealed record ValueColumn(string Name) : PendingStatementParameter; } +internal class RawTableJsonConverter : JsonConverter +{ + public override RawTable ReadJson(JsonReader reader, Type objectType, RawTable? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + throw new NotSupportedException("Deserializing RawTable is not supported."); + } + + // Flattens RawTable into { name, put, delete, clear, table_name, synced_columns, options }. + public override void WriteJson(JsonWriter writer, RawTable? value, JsonSerializer serializer) + { + if (value is null) + { + writer.WriteNull(); + return; + } + + writer.WriteStartObject(); + + // TODO: Should we include null properties or strip them? + // TODO: Clean this up with a better implementation probably + writer.WritePropertyName("name"); + writer.WriteValue(value.Name); + + writer.WritePropertyName("put"); + serializer.Serialize(writer, value.Put); + + writer.WritePropertyName("delete"); + serializer.Serialize(writer, value.Delete); + + writer.WritePropertyName("clear"); + writer.WriteValue(value.Name); + + writer.WritePropertyName("table_name"); + writer.WriteValue(value.Schema?.TableName ?? value.Name); + + writer.WritePropertyName("synced_columns"); + serializer.Serialize(writer, value.Schema?.SyncedColumns); + + writer.WritePropertyName("options"); + serializer.Serialize(writer, value.Schema?.Options); + + writer.WriteEndObject(); + } +} + internal class PendingStatementParameterJsonConverter : JsonConverter { - // We never need to deserialize pending stamements. public override PendingStatementParameter ReadJson(JsonReader reader, Type objectType, PendingStatementParameter? existingValue, bool hasExistingValue, JsonSerializer serializer) { throw new NotSupportedException("Deserializing PendingStatementParameter is not supported."); } - public override void WriteJson(JsonWriter writer, PendingStatementParameter value, JsonSerializer _serializer) + public override void WriteJson(JsonWriter writer, PendingStatementParameter? value, JsonSerializer serializer) { + if (value is null) + { + writer.WriteNull(); + return; + } + if (value == PendingStatementParameter.Id) { writer.WriteValue("Id"); } else if (value is PendingStatementParameter.ValueColumn column) { - JObject json = new JObject(new JProperty("Column", column.Name)); - writer.WriteValue(json); + var json = new JObject(new JProperty("Column", column.Name)); + json.WriteTo(writer); } else if (value == PendingStatementParameter.Rest) { diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index 6e6fccf2..af72cb61 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -41,6 +41,19 @@ public class TableOptions( /// CRUD entries. /// public bool IgnoreEmptyUpdates { get; set; } = ignoreEmptyUpdates ?? false; + + public void Validate() + { + if (TrackMetadata && LocalOnly) + { + throw new Exception("Can't include metadata for local-only tables."); + } + + if (TrackPreviousValues != null && LocalOnly) + { + throw new Exception("Can't include old values for local-only tables."); + } + } } /// @@ -176,15 +189,7 @@ public override void Validate() $"Table has too many columns. The maximum number of columns is {MAX_AMOUNT_OF_COLUMNS}."); } - if (Options.TrackMetadata && Options.LocalOnly) - { - throw new Exception("Can't include metadata for local-only tables."); - } - - if (Options.TrackPreviousValues != null && Options.LocalOnly) - { - throw new Exception("Can't include old values for local-only tables."); - } + Options.Validate(); var columnNames = new HashSet { "id" }; diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs new file mode 100644 index 00000000..1a19e773 --- /dev/null +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs @@ -0,0 +1,82 @@ +using Newtonsoft.Json; + +using PowerSync.Common.Client; +using PowerSync.Common.DB.Schema; + +using PowerSync.Common.Tests.Utils; + +namespace PowerSync.Common.Tests.DB; + +/// +/// dotnet test -v n --framework net10.0 --filter "SchemaTests" +/// +public class RawTableTests +{ + [Fact(Timeout = 5000)] + public async Task RawTablesDoesNotCreateViews() + { + // TODO Move assets table out of just this function + var assets = new RawTable( + name: "assets", + schema: new RawTableSchema() + ); + var schema = new Common.DB.Schema.Schema(assets); + + var dbName = DatabaseUtils.NewDbName(); + var db = NewDatabase(dbName, schema); + + try + { + await db.Execute(@" + CREATE TABLE assets ( + id TEXT PRIMARY KEY, + created_at TEXT, + make TEXT, + model TEXT, + serial_number TEXT, + quantity INTEGER, + user_id TEXT, + customer_id TEXT, + description TEXT, + ); + "); + + foreach (string action in new string[] { "INSERT", "UPDATE", "DELETE" }) + { + await db.Execute( + "SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", + [JsonConvert.SerializeObject(assets), $"assets_{action}", action] + ); + } + + string tableType = await db.Get("SELECT type as r FROM sqlite_master WHERE name = 'assets'"); + Assert.Equal("table", tableType); + Assert.NotEqual("view", tableType); + + int powerSyncTableCount = await db.Get(@" + SELECT count(*) as r + FROM sqlite_master + WHERE name = 'ps_data__assets' + OR name = 'ps_data_local__assets'"); + Assert.Equal(0, powerSyncTableCount); + } + finally + { + await db.DisconnectAndClear(); + await db.Close(); + DatabaseUtils.CleanDb(dbName); + } + } + + private static PowerSyncDatabase NewDatabase(string name, Common.DB.Schema.Schema schema) + { + return new PowerSyncDatabase(new PowerSyncDatabaseOptions() + { + Database = new SQLOpenOptions() + { + DbFilename = name, + }, + Schema = schema, + }); + } +} diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Utils/DatabaseUtils.cs b/Tests/PowerSync/PowerSync.Common.Tests/Utils/DatabaseUtils.cs index d82e754f..883c06ce 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Utils/DatabaseUtils.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Utils/DatabaseUtils.cs @@ -36,4 +36,9 @@ private static void TryDelete(string filePath) Console.Error.WriteLine($"Failed to delete file after {retryCount} attempts: {filePath}"); } + + public static string NewDbName(string? dbIdentifier = null) + { + return $"test-{dbIdentifier ?? "database"}-{Guid.NewGuid()}.db"; + } } From 932615b884700f0a9c9b1c9aef5563bb7f791d67 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 1 Oct 2026 14:04:42 +0200 Subject: [PATCH 14/18] use Options.Schema --- .../Client/Sync/Stream/StreamingSyncImplementation.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs index ec854e0b..63c4b0b9 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs @@ -930,7 +930,7 @@ async Task HandleInstruction(NonInterruptingInstruction instruction) var options = new { parameters = resolvedOptions.Params, - schema = resolvedOptions.Schema, + schema = Options.Schema, active_streams = activeStreams, include_defaults = resolvedOptions.IncludeDefaultStreams, app_metadata = resolvedOptions.AppMetadata, From f654aecd3e1c5ceac5a80164095cc965f7387d75 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 1 Oct 2026 15:49:02 +0200 Subject: [PATCH 15/18] tests, fix serialization --- .../PowerSync.Common/DB/Schema/RawTable.cs | 68 +++++++-- .../DB/RawTableTests.cs | 134 +++++++++++------- 2 files changed, 139 insertions(+), 63 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs index e0199ce6..a72465e8 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs @@ -138,28 +138,68 @@ public override void WriteJson(JsonWriter writer, RawTable? value, JsonSerialize writer.WriteStartObject(); - // TODO: Should we include null properties or strip them? - // TODO: Clean this up with a better implementation probably writer.WritePropertyName("name"); writer.WriteValue(value.Name); - writer.WritePropertyName("put"); - serializer.Serialize(writer, value.Put); + if (value.Put is not null) + { + writer.WritePropertyName("put"); + serializer.Serialize(writer, value.Put); + } - writer.WritePropertyName("delete"); - serializer.Serialize(writer, value.Delete); + if (value.Delete is not null) + { + writer.WritePropertyName("delete"); + serializer.Serialize(writer, value.Delete); + } - writer.WritePropertyName("clear"); - writer.WriteValue(value.Name); + if (value.Clear is not null) + { + writer.WritePropertyName("clear"); + writer.WriteValue(value.Clear); + } + + if (value.Schema is { } schema) + { + writer.WritePropertyName("table_name"); + writer.WriteValue(schema.TableName ?? value.Name); - writer.WritePropertyName("table_name"); - writer.WriteValue(value.Schema?.TableName ?? value.Name); + if (schema.SyncedColumns is not null) + { + writer.WritePropertyName("synced_columns"); + serializer.Serialize(writer, schema.SyncedColumns); + } + + var options = schema.Options; + + writer.WritePropertyName("local_only"); + writer.WriteValue(options.LocalOnly); + + writer.WritePropertyName("insert_only"); + writer.WriteValue(options.InsertOnly); - writer.WritePropertyName("synced_columns"); - serializer.Serialize(writer, value.Schema?.SyncedColumns); + writer.WritePropertyName("ignore_empty_update"); + writer.WriteValue(options.IgnoreEmptyUpdates); - writer.WritePropertyName("options"); - serializer.Serialize(writer, value.Schema?.Options); + writer.WritePropertyName("include_metadata"); + writer.WriteValue(options.TrackMetadata); + + if (options.TrackPreviousValues is { } trackPrevious) + { + writer.WritePropertyName("include_old"); + if (trackPrevious.Columns is null) + { + writer.WriteValue(true); + } + else + { + serializer.Serialize(writer, trackPrevious.Columns); + } + + writer.WritePropertyName("include_old_only_when_changed"); + writer.WriteValue(trackPrevious.OnlyWhenChanged); + } + } writer.WriteEndObject(); } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs index 1a19e773..75b44643 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs @@ -8,73 +8,109 @@ namespace PowerSync.Common.Tests.DB; /// -/// dotnet test -v n --framework net10.0 --filter "SchemaTests" +/// dotnet test -v n --framework net10.0 --filter "RawTableTests" /// -public class RawTableTests +public class RawTableTests : IAsyncLifetime { + private string _dbName = ""; + private PowerSyncDatabase _db = null!; + + public async Task InitializeAsync() + { + _dbName = DatabaseUtils.NewDbName(); + } + + public async Task DisposeAsync() + { + if (_db != null) + { + await _db.DisconnectAndClear(); + await _db.Close(); + _db = null!; + } + if (_dbName != null) + { + DatabaseUtils.CleanDb(_dbName); + _dbName = ""; + } + } + [Fact(Timeout = 5000)] - public async Task RawTablesDoesNotCreateViews() + public async Task RawTableDoesNotCreateViews() { - // TODO Move assets table out of just this function - var assets = new RawTable( - name: "assets", - schema: new RawTableSchema() - ); + var assets = CreateAssetsSchema(); var schema = new Common.DB.Schema.Schema(assets); + NewDatabase(schema); + await CreateAssetsTable(assets); - var dbName = DatabaseUtils.NewDbName(); - var db = NewDatabase(dbName, schema); + string tableType = (await _db.Get("SELECT type as r FROM sqlite_master WHERE name = 'assets'")).r; + Assert.Equal("table", tableType); + Assert.NotEqual("view", tableType); - try - { - await db.Execute(@" - CREATE TABLE assets ( - id TEXT PRIMARY KEY, - created_at TEXT, - make TEXT, - model TEXT, - serial_number TEXT, - quantity INTEGER, - user_id TEXT, - customer_id TEXT, - description TEXT, - ); - "); + long powerSyncTableCount = await _db.Get(@" + SELECT count(*) + FROM sqlite_master + WHERE name = 'ps_data__assets' + OR name = 'ps_data_local__assets'"); + Assert.Equal(0L, powerSyncTableCount); + } - foreach (string action in new string[] { "INSERT", "UPDATE", "DELETE" }) - { - await db.Execute( - "SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", - [JsonConvert.SerializeObject(assets), $"assets_{action}", action] - ); - } + [Fact(Timeout = 5000)] + public async Task RawTableCreatesRowsInInternalTables() + { + var assets = CreateAssetsSchema(); + var schema = new Common.DB.Schema.Schema(assets); + NewDatabase(schema); + await CreateAssetsTable(assets); - string tableType = await db.Get("SELECT type as r FROM sqlite_master WHERE name = 'assets'"); - Assert.Equal("table", tableType); - Assert.NotEqual("view", tableType); + Assert.Equal(0L, await _db.Get("SELECT count(*) FROM ps_crud")); + await _db.Execute( + "INSERT INTO assets (id, make, model) VALUES (uuid(), ?, ?)", + ["test make", "test model"] + ); + Assert.Equal(1L, await _db.Get("SELECT count(*) FROM ps_crud")); + } - int powerSyncTableCount = await db.Get(@" - SELECT count(*) as r - FROM sqlite_master - WHERE name = 'ps_data__assets' - OR name = 'ps_data_local__assets'"); - Assert.Equal(0, powerSyncTableCount); - } - finally + private static RawTable CreateAssetsSchema(RawTableSchema? customSchema = null) + { + return new RawTable( + name: "assets", + schema: customSchema ?? new RawTableSchema() + ); + } + + private async Task CreateAssetsTable(RawTable assetsSchema) + { + await _db.Execute(@" + CREATE TABLE assets ( + id TEXT PRIMARY KEY, + created_at TEXT, + make TEXT, + model TEXT, + serial_number TEXT, + quantity INTEGER, + user_id TEXT, + customer_id TEXT, + description TEXT + ); + "); + + foreach (string action in new string[] { "INSERT", "UPDATE", "DELETE" }) { - await db.DisconnectAndClear(); - await db.Close(); - DatabaseUtils.CleanDb(dbName); + await _db.Execute( + "SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", + [JsonConvert.SerializeObject(assetsSchema), $"assets_{action}", action] + ); } } - private static PowerSyncDatabase NewDatabase(string name, Common.DB.Schema.Schema schema) + private void NewDatabase(Common.DB.Schema.Schema schema) { - return new PowerSyncDatabase(new PowerSyncDatabaseOptions() + _db = new PowerSyncDatabase(new PowerSyncDatabaseOptions() { Database = new SQLOpenOptions() { - DbFilename = name, + DbFilename = _dbName, }, Schema = schema, }); From 69942e862b394ee1fdd3ef0b573d0311e1c48e62 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Fri, 2 Oct 2026 10:08:34 +0200 Subject: [PATCH 16/18] fix missing raw_tables in schema --- Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 29aea943..993b1536 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -379,7 +379,8 @@ public void Schema_SerializesToJSON() include_old = false, include_old_only_when_changed = false }, - } + }, + raw_tables = new List() }; Assert.Equal(JsonConvert.SerializeObject(expectedJson), JsonConvert.SerializeObject(TestSchemaTodoList.AppSchema)); } @@ -419,7 +420,8 @@ public void Schema_SerializesHyphenatedColumnNames() include_old = false, include_old_only_when_changed = false }, - } + }, + raw_tables = new List() }; var schema = new Schema(new Table From 8ebd41c4cd5f455ccd4363d165b309e35798a66e Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Fri, 2 Oct 2026 12:36:25 +0200 Subject: [PATCH 17/18] port tests from Kotlin/JS, fix serialization error --- .../PowerSync.Common/DB/Schema/RawTable.cs | 45 +-- .../DB/RawTableTests.cs | 273 +++++++++++++++++- .../Utils/Sync/MockSyncService.cs | 5 +- 3 files changed, 298 insertions(+), 25 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs index a72465e8..64b710c5 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs @@ -100,7 +100,7 @@ private PendingStatementParameter() { } /// /// Resolves to the ID of the affected row. /// - public static readonly PendingStatementParameter Id = new(); + public static readonly PendingStatementParameter Id = new ValueId(); /// /// Resolves to the value of a column in the added row. @@ -114,10 +114,16 @@ private PendingStatementParameter() { } /// Resolves to a JSON object containing all columns from the synced row that haven't been /// matched by a value in the same statement. /// - public static readonly PendingStatementParameter Rest = new(); + public static readonly PendingStatementParameter Rest = new ValueRest(); /// - public sealed record ValueColumn(string Name) : PendingStatementParameter; + internal sealed record ValueColumn(string Name) : PendingStatementParameter; + + /// + internal sealed record ValueId : PendingStatementParameter; + + /// + internal sealed record ValueRest : PendingStatementParameter; } internal class RawTableJsonConverter : JsonConverter @@ -197,7 +203,7 @@ public override void WriteJson(JsonWriter writer, RawTable? value, JsonSerialize } writer.WritePropertyName("include_old_only_when_changed"); - writer.WriteValue(trackPrevious.OnlyWhenChanged); + writer.WriteValue(trackPrevious.OnlyWhenChanged ?? false); } } @@ -220,22 +226,23 @@ public override void WriteJson(JsonWriter writer, PendingStatementParameter? val return; } - if (value == PendingStatementParameter.Id) - { - writer.WriteValue("Id"); - } - else if (value is PendingStatementParameter.ValueColumn column) - { - var json = new JObject(new JProperty("Column", column.Name)); - json.WriteTo(writer); - } - else if (value == PendingStatementParameter.Rest) + switch (value) { - writer.WriteValue("Rest"); - } - else - { - throw new InvalidOperationException("Incorrect type for given PendingStatementParameter."); + case PendingStatementParameter.ValueId: + writer.WriteValue("Id"); + break; + + case PendingStatementParameter.ValueColumn column: + var json = new JObject(new JProperty("Column", column.Name)); + json.WriteTo(writer); + break; + + case PendingStatementParameter.ValueRest: + writer.WriteValue("Rest"); + break; + + default: + throw new InvalidOperationException("Incorrect type for given PendingStatementParameter."); } } } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs index 75b44643..344e98a4 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/RawTableTests.cs @@ -1,9 +1,13 @@ using Newtonsoft.Json; using PowerSync.Common.Client; +using PowerSync.Common.Client.Sync.Bucket; +using PowerSync.Common.Client.Sync.Stream; +using PowerSync.Common.DB.Crud; using PowerSync.Common.DB.Schema; using PowerSync.Common.Tests.Utils; +using PowerSync.Common.Tests.Utils.Sync; namespace PowerSync.Common.Tests.DB; @@ -14,6 +18,7 @@ public class RawTableTests : IAsyncLifetime { private string _dbName = ""; private PowerSyncDatabase _db = null!; + private MockSyncService? _syncService; public async Task InitializeAsync() { @@ -28,6 +33,7 @@ public async Task DisposeAsync() await _db.Close(); _db = null!; } + _syncService?.Close(); if (_dbName != null) { DatabaseUtils.CleanDb(_dbName); @@ -71,6 +77,262 @@ await _db.Execute( Assert.Equal(1L, await _db.Get("SELECT count(*) FROM ps_crud")); } + [Fact] + public void Schema_SerializesRawTablesToJSON() + { + var schema = new Common.DB.Schema.Schema( + new RawTable( + name: "lists", + put: new PendingStatement("SELECT 1", [PendingStatementParameter.Column("foo"), PendingStatementParameter.Rest]), + delete: new PendingStatement("SELECT 2", [PendingStatementParameter.Id]) + ), + new RawTable( + name: "sync_name", + schema: new RawTableSchema + { + TableName = "users", + SyncedColumns = ["name"], + Options = new TableOptions + { + IgnoreEmptyUpdates = true, + TrackPreviousValues = new TrackPreviousOptions(), + } + }, + clear: "DELETE FROM users" + ) + ); + + object expectedJson = new + { + tables = new List(), + raw_tables = new List + { + new + { + name = "lists", + put = new + { + sql = "SELECT 1", + @params = new List { new { Column = "foo" }, "Rest" } + }, + delete = new + { + sql = "SELECT 2", + @params = new List { "Id" } + } + }, + new + { + name = "sync_name", + clear = "DELETE FROM users", + table_name = "users", + synced_columns = new List { "name" }, + local_only = false, + insert_only = false, + ignore_empty_update = true, + include_metadata = false, + include_old = true, + include_old_only_when_changed = false + } + } + }; + + Assert.Equal(JsonConvert.SerializeObject(expectedJson), JsonConvert.SerializeObject(schema)); + } + + [Fact(Timeout = 5000)] + public async Task InferredCrudTrigger() + { + var table = new RawTable("users", new RawTableSchema()); + NewDatabase(new Common.DB.Schema.Schema(table)); + + await _db.Execute("CREATE TABLE users (id TEXT, name TEXT);"); + await CreateTrigger(table, "users_insert", "INSERT"); + + await _db.Execute("INSERT INTO users (id, name) VALUES (?, ?);", ["id", "user"]); + + var tx = await _db.GetNextCrudTransaction(); + Assert.NotNull(tx); + var write = Assert.Single(tx.Crud); + Assert.Equal(UpdateType.PUT, write.Op); + Assert.Equal("users", write.Table); + Assert.Equal("id", write.Id); + Assert.Equal(new Dictionary { ["name"] = "user" }, write.OpData); + } + + [Fact(Timeout = 5000)] + public async Task CrudTriggerWithOptions() + { + var table = new RawTable( + name: "sync_name", + schema: new RawTableSchema + { + TableName = "users", + SyncedColumns = ["name"], + Options = new TableOptions + { + IgnoreEmptyUpdates = true, + TrackPreviousValues = new TrackPreviousOptions(), + } + } + ); + NewDatabase(new Common.DB.Schema.Schema(table)); + + await _db.Execute("CREATE TABLE users (id TEXT, name TEXT, local TEXT);"); + await _db.Execute("INSERT INTO users (id, name, local) VALUES (?, ?, ?);", ["id", "name", "local"]); + await CreateTrigger(table, "users_update", "UPDATE"); + + await _db.Execute("UPDATE users SET name = ?, local = ?;", ["updated_name", "updated_local"]); + // This should not generate a CRUD entry because the only synced column is not affected. + await _db.Execute("UPDATE users SET name = ?, local = ?;", ["updated_name", "updated_local_2"]); + + var tx = await _db.GetNextCrudTransaction(); + Assert.NotNull(tx); + var write = Assert.Single(tx.Crud); + Assert.Equal(UpdateType.PATCH, write.Op); + Assert.Equal("sync_name", write.Table); + Assert.Equal("id", write.Id); + // These should not include the local-only column + Assert.Equal(new Dictionary { ["name"] = "updated_name" }, write.OpData); + Assert.Equal(new Dictionary { ["name"] = "name" }, write.PreviousValues); + } + + [Fact(Timeout = 5000)] + public async Task DisconnectAndClearRunsClearStatement() + { + NewDatabase(new Common.DB.Schema.Schema( + new RawTable("users", new RawTableSchema { TableName = "lists" }, clear: "DELETE FROM lists") + )); + + await _db.Execute("CREATE TABLE lists (id TEXT NOT NULL PRIMARY KEY, name TEXT)"); + await _db.Execute("INSERT INTO lists (id, name) VALUES (uuid(), ?)", ["list"]); + + Assert.Single(await _db.GetAll("SELECT * FROM lists")); + await _db.DisconnectAndClear(); + Assert.Empty(await _db.GetAll("SELECT * FROM lists")); + } + + [Fact(Timeout = 15000)] + public async Task SyncWithInferredStatements() + { + NewSyncedDatabase(new Common.DB.Schema.Schema( + new RawTable("lists", new RawTableSchema()) + )); + await _db.Execute("CREATE TABLE lists (id TEXT NOT NULL PRIMARY KEY, name TEXT);"); + + var query = _db.Watch("SELECT * FROM lists", null, new() { TriggerImmediately = true }).GetAsyncEnumerator(); + await query.MoveNextAsync(); + Assert.Empty(query.Current); + + await _db.Connect(new TestConnector()); + + PushPut(opId: 1, data: """{"name": "custom list"}"""); + await _db.WaitForFirstSync(); + + await query.MoveNextAsync(); + var row = Assert.Single(query.Current); + Assert.Equal("my_list", row.id); + Assert.Equal("custom list", row.name); + + PushRemove(opId: 2); + + await query.MoveNextAsync(); + Assert.Empty(query.Current); + } + + [Fact(Timeout = 15000)] + public async Task SyncWithExplicitStatements() + { + NewSyncedDatabase(new Common.DB.Schema.Schema( + new RawTable( + name: "lists", + put: new PendingStatement( + "INSERT OR REPLACE INTO lists (id, name, _rest) VALUES (?, ?, ?)", + [PendingStatementParameter.Id, PendingStatementParameter.Column("name"), PendingStatementParameter.Rest] + ), + delete: new PendingStatement( + "DELETE FROM lists WHERE id = ?", + [PendingStatementParameter.Id] + ) + ) + )); + await _db.Execute("CREATE TABLE lists (id TEXT NOT NULL PRIMARY KEY, name TEXT, _rest TEXT);"); + + var query = _db.Watch("SELECT * FROM lists", null, new() { TriggerImmediately = true }).GetAsyncEnumerator(); + await query.MoveNextAsync(); + Assert.Empty(query.Current); + + await _db.Connect(new TestConnector()); + + PushPut(opId: 1, data: """{"name": "custom list", "additional": "foo"}"""); + await _db.WaitForFirstSync(); + + await query.MoveNextAsync(); + var row = Assert.Single(query.Current); + Assert.Equal("my_list", row.id); + Assert.Equal("custom list", row.name); + Assert.Equal("""{"additional":"foo"}""", row._rest); + + PushRemove(opId: 2); + + await query.MoveNextAsync(); + Assert.Empty(query.Current); + } + + private class ListRow + { + public string id { get; set; } = ""; + public string? name { get; set; } + public string? _rest { get; set; } + } + + private void PushPut(long opId, string data) + { + PushOperation(opId, new OplogEntryJSON + { + Checksum = 0, + OpId = opId, + Op = "PUT", + ObjectId = "my_list", + ObjectType = "lists", + Data = data, + }); + } + + private void PushRemove(long opId) + { + PushOperation(opId, new OplogEntryJSON + { + Checksum = 0, + OpId = opId, + Op = "REMOVE", + ObjectId = "my_list", + ObjectType = "lists", + }); + } + + private void PushOperation(long opId, OplogEntryJSON entry) + { + _syncService!.PushLine(MockDataFactory.Checkpoint(opId, [MockDataFactory.Bucket("a", (int)opId, subscriptions: Array.Empty())])); + _syncService.PushLine(new StreamingSyncDataJSON + { + Data = new SyncDataBucketJSON + { + Bucket = "a", + Data = [entry] + } + }); + _syncService.PushLine(MockDataFactory.CheckpointComplete(opId.ToString())); + } + + private async Task CreateTrigger(RawTable table, string name, string write) + { + await _db.Execute( + "SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", + [JsonConvert.SerializeObject(table), name, write] + ); + } + private static RawTable CreateAssetsSchema(RawTableSchema? customSchema = null) { return new RawTable( @@ -97,10 +359,7 @@ description TEXT foreach (string action in new string[] { "INSERT", "UPDATE", "DELETE" }) { - await _db.Execute( - "SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", - [JsonConvert.SerializeObject(assetsSchema), $"assets_{action}", action] - ); + await CreateTrigger(assetsSchema, $"assets_{action}", action); } } @@ -115,4 +374,10 @@ private void NewDatabase(Common.DB.Schema.Schema schema) Schema = schema, }); } + + private void NewSyncedDatabase(Common.DB.Schema.Schema schema) + { + _syncService = new MockSyncService(); + _db = _syncService.CreateDatabase(dbFilename: _dbName, schema: schema); + } } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs b/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs index 8d897fc7..94f441fa 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs @@ -11,6 +11,7 @@ using PowerSync.Common.Client.Sync.Bucket; using PowerSync.Common.Client.Sync.Stream; using PowerSync.Common.DB.Crud; +using PowerSync.Common.DB.Schema; using PowerSync.Common.Utils; @@ -89,7 +90,7 @@ public void PushLine(string line) Emit(line); } - public PowerSyncDatabase CreateDatabase(string? dbFilename = null, TimeProvider? timeProvider = null) + public PowerSyncDatabase CreateDatabase(string? dbFilename = null, TimeProvider? timeProvider = null, Schema? schema = null) { dbFilename ??= $"sync-stream-{Guid.NewGuid():N}.db"; var connector = new TestConnector(); @@ -98,7 +99,7 @@ public PowerSyncDatabase CreateDatabase(string? dbFilename = null, TimeProvider? return new PowerSyncDatabase(new PowerSyncDatabaseOptions { Database = new SQLOpenOptions { DbFilename = dbFilename }, - Schema = TestSchemaTodoList.AppSchema, + Schema = schema ?? TestSchemaTodoList.AppSchema, RemoteFactory = _ => mockRemote, TimeProvider = timeProvider, Logger = CreateLogger() From 2d8d31fb515576c11e4e63e447c2786ba475c480 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Fri, 2 Oct 2026 12:44:46 +0200 Subject: [PATCH 18/18] more tests, validation logic --- .../PowerSync.Common/DB/Schema/RawTable.cs | 4 +++- .../PowerSync.Common/DB/Schema/Schema.cs | 11 +++++---- .../PowerSync.Common.Tests/DB/SchemaTests.cs | 24 +++++++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs index 64b710c5..1eac9612 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/RawTable.cs @@ -12,10 +12,12 @@ public class RawTable : BaseTable public PendingStatement? Delete { get; set; } public string? Clear { get; set; } - public RawTable(string name, RawTableSchema schema, string? clear = null) + public RawTable(string name, RawTableSchema schema, PendingStatement? put = null, PendingStatement? delete = null, string? clear = null) { Name = name; Schema = schema; + Put = put; + Delete = delete; Clear = clear; } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs index 99e4f757..4c48d6a4 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs @@ -51,14 +51,15 @@ private void RegisterType(Type type) public void Validate() { - foreach (var table in _tables) + var tableNames = new HashSet(); + foreach (var table in AllTables) { + if (!tableNames.Add(table.Name)) + { + throw new InvalidOperationException($"Duplicate table name: {table.Name}"); + } table.Validate(); } - foreach (var rawTable in _rawTables) - { - rawTable.Validate(); - } } } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 993b1536..e01f59e4 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -321,6 +321,30 @@ public void AttributeParser_TypeMap_DefaultRegistered() Assert.True(typeMap is Dapper.DefaultTypeMap); } + [Fact] + public void Schema_Validate_RejectsDuplicateTableNames() + { + var schema = new Schema( + new Table("lists", new Dictionary { ["name"] = ColumnType.Text }), + new Table("lists", new Dictionary { ["title"] = ColumnType.Text }) + ); + + var ex = Assert.Throws(schema.Validate); + Assert.Contains("Duplicate table name: lists", ex.Message); + } + + [Fact] + public void Schema_Validate_RejectsDuplicateRawTableNames() + { + var schema = new Schema( + new Table("lists", new Dictionary { ["name"] = ColumnType.Text }), + new RawTable("lists", new RawTableSchema()) + ); + + var ex = Assert.Throws(schema.Validate); + Assert.Contains("Duplicate table name: lists", ex.Message); + } + [Fact] public void Schema_SerializesToJSON() {