From 44b288618312559a3ed4c9cd2a4d076751b91443 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 12 Feb 2026 14:25:59 +0200 Subject: [PATCH 01/14] First stab --- .../Client/PowerSyncDatabase.cs | 164 ++++++++++++------ 1 file changed, 114 insertions(+), 50 deletions(-) diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index ef78479..d21ed42 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -748,7 +748,7 @@ public async Task WriteTransaction(Func> fn, DBLockO /// Use to specify the minimum interval between queries. /// Source tables are automatically detected using EXPLAIN QUERY PLAN. /// - public Task Watch(string query, object?[]? parameters, WatchHandler handler, SQLWatchOptions? options = null) + public Task Watch(string query, object?[]? parameters, WatchHandler handler, SQLWatchOptions? options = null) => Task.Run(() => WatchInternal(query, parameters, handler, options, GetAll)); /// @@ -757,10 +757,10 @@ public Task Watch(string query, object?[]? parameters, WatchHand /// Use to specify the minimum interval between queries. /// Source tables are automatically detected using EXPLAIN QUERY PLAN. /// - public Task Watch(string query, object?[]? parameters, WatchHandler handler, SQLWatchOptions? options = null) + public Task Watch(string query, object?[]? parameters, WatchHandler handler, SQLWatchOptions? options = null) => Task.Run(() => WatchInternal(query, parameters, handler, options, GetAll)); - private async Task WatchInternal( + private async Task WatchInternal( string query, object?[]? parameters, WatchHandler handler, @@ -768,41 +768,61 @@ private async Task WatchInternal( Func> getter ) { - try - { - var resolvedTables = await ResolveTables(query, parameters, options); - var result = await getter(query, parameters); - handler.OnResult(result); + var subscription = new WatchSubscription(); - var subscription = OnChange(new WatchOnChangeHandler + async Task ResetQuery() + { + try { - OnChange = async (change) => + var resolvedTables = await ResolveTables(query, parameters, options); + var result = await getter(query, parameters); + handler.OnResult(result); + + var onChangeListener = OnChange(new WatchOnChangeHandler { - try + OnChange = async (change) => { - var result = await getter(query, parameters); - handler.OnResult(result); - } - catch (Exception ex) - { - handler.OnError?.Invoke(ex); - } - }, - OnError = handler.OnError - }, new SQLWatchOptions - { - Tables = resolvedTables, - Signal = options?.Signal, - ThrottleMs = options?.ThrottleMs - }); + try + { + var result = await getter(query, parameters); + handler.OnResult(result); + } + catch (Exception ex) + { + handler.OnError?.Invoke(ex); + } + }, + OnError = handler.OnError + }, new SQLWatchOptions + { + Tables = resolvedTables, + Signal = options?.Signal, + ThrottleMs = options?.ThrottleMs + }); - return subscription; + subscription.SetOnChangeListener(onChangeListener); + } + catch (Exception ex) + { + handler.OnError?.Invoke(ex); + throw; + } } - catch (Exception ex) + + // Register initial subscription + await ResetQuery(); + + // Listen for schema changes and reset listener + var schemaListener = RunListener(async (e) => { - handler.OnError?.Invoke(ex); - throw; - } + if (e.SchemaChanged != null) + { + await ResetQuery(); + } + }); + subscription.SetSchemaListener(schemaListener); + + return subscription; } private class ExplainedResult @@ -869,7 +889,7 @@ void flushTableUpdates() }); } - var cts = Database.RunListener((update) => + var dbListenerCts = Database.RunListener((update) => { if (update.TablesUpdated != null) { @@ -885,27 +905,29 @@ void flushTableUpdates() } }); - CancellationTokenSource linkedCts; - if (options?.Signal.HasValue == true) + CancellationTokenSource stopRunningCts; + + if (options?.Signal != null) { - // Cancel on global CTS cancellation or user token cancellation - linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( watchSubscriptionCts.Token, options.Signal.Value ); + stopRunningCts = linkedCts; } else { - // Cancel on global CTS cancellation - linkedCts = watchSubscriptionCts; + stopRunningCts = watchSubscriptionCts; } - var registration = linkedCts.Token.Register(() => + var stopRunningReg = stopRunningCts.Token.Register(stopRunningCts.Cancel); + + return new ActionDisposable(() => { - cts.Cancel(); + stopRunningCts.Dispose(); + dbListenerCts.Cancel(); + dbListenerCts.Dispose(); }); - - return new WatchSubscription(cts, registration); } private static void HandleTableChanges(HashSet changedTables, HashSet watchedTables, Action onDetectedChanges) @@ -968,21 +990,63 @@ public class WatchOnChangeHandler public Action? OnError { get; set; } } -public class WatchSubscription(CancellationTokenSource cts, CancellationTokenRegistration registration) : IDisposable +public class WatchSubscription : IDisposable { - private readonly CancellationTokenSource _cts = cts; - private readonly CancellationTokenRegistration _registration = registration; + private IDisposable? _onChangeListener; + private IDisposable? _schemaListener; + private readonly object _lock = new(); private bool _disposed; - public bool Disposed { get { return _disposed; } } + internal void SetSchemaListener(IDisposable listener) + { + lock (_lock) + { + if (_disposed) + { + listener.Dispose(); + return; + } + _schemaListener?.Dispose(); + _schemaListener = listener; + } + } + + internal void SetOnChangeListener(IDisposable listener) + { + lock (_lock) + { + if (_disposed) + { + listener.Dispose(); + return; + } + _onChangeListener?.Dispose(); + _onChangeListener = listener; + } + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + + _onChangeListener?.Dispose(); + _schemaListener?.Dispose(); + } + } +} + +public class ActionDisposable(Action onDispose) : IDisposable +{ + private readonly Action _onDispose = onDispose; + private bool _disposed = false; public void Dispose() { if (_disposed) return; _disposed = true; - - _registration.Dispose(); - _cts.Cancel(); - _cts.Dispose(); + _onDispose(); } } From 928d7bf511dedf8baaa506be35fd971aca1cb720 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 12 Feb 2026 15:11:38 +0200 Subject: [PATCH 02/14] Block out test, fix error in Table properties visibility --- PowerSync/PowerSync.Common/DB/Schema/Table.cs | 8 +- .../Client/PowerSyncDatabaseTests.cs | 80 +++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index bc4b01b..0517b39 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -83,22 +83,22 @@ public bool InsertOnly get { return Options.InsertOnly; } set { Options.InsertOnly = value; } } - string? ViewName + public string? ViewName { get { return Options.ViewName; } set { Options.ViewName = value; } } - bool TrackMetadata + public bool TrackMetadata { get { return Options.TrackMetadata; } set { Options.TrackMetadata = value; } } - TrackPreviousOptions? TrackPreviousValues + public TrackPreviousOptions? TrackPreviousValues { get { return Options.TrackPreviousValues; } set { Options.TrackPreviousValues = value; } } - bool IgnoreEmptyUpdates + public bool IgnoreEmptyUpdates { get { return Options.IgnoreEmptyUpdates; } set { Options.IgnoreEmptyUpdates = value; } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index d3af172..cb2d8b1 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -5,6 +5,7 @@ namespace PowerSync.Common.Tests.Client; using Microsoft.Data.Sqlite; using PowerSync.Common.Client; +using PowerSync.Common.DB.Schema; /// /// dotnet test -v n --framework net8.0 --filter "PowerSyncDatabaseTests" @@ -691,4 +692,83 @@ await db.Execute( await semAlwaysRunning.WaitAsync(); Assert.Equal(5, callCount); } + + [Fact] + public async Task WatchSchemaResetTest() + { + var initialSchema = new Schema( + new Table + { + Name = "assets_local", + ViewName = "assets", + Columns = + { + ["make"] = ColumnType.Text, + ["model"] = ColumnType.Text, + ["description"] = ColumnType.Text, + } + }, + new Table + { + Name = "assets_synced", + ViewName = "assets_inactive", + Columns = + { + ["make"] = ColumnType.Text, + ["model"] = ColumnType.Text, + ["description"] = ColumnType.Text, + } + } + ); + var updatedSchema = new Schema( + new Table + { + Name = "assets_local", + ViewName = "assets_inactive", + Columns = + { + ["make"] = ColumnType.Text, + ["model"] = ColumnType.Text, + ["description"] = ColumnType.Text, + } + }, + new Table + { + Name = "assets_synced", + ViewName = "assets", + Columns = + { + ["make"] = ColumnType.Text, + ["model"] = ColumnType.Text, + ["description"] = ColumnType.Text, + } + } + ); + + var dbId = Guid.NewGuid().ToString(); + var db = new PowerSyncDatabase(new() + { + Database = new SQLOpenOptions + { + DbFilename = $"powerSyncWatch_{dbId}.db", + }, + Schema = initialSchema + }); + + // TODO: Setup query watching 'assets' + // Setup semaphore to track successful watches + + // TODO: INSERT into 'assets' + // Verify correct count (3) + + // TODO: Update schema to change underlying table name without changing view name + + // TODO: INSERT all data from "assets_local" into "assets_synced" + // Verify correct count (6) + + // ** Sanity check ** + // TODO: Deregister query + // Change schema back to initial and update data + // Verify correct count (6) (unchanged) + } } From 8b1c42d8f18d2ad0c6677babca4e269317f0f1d7 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 12 Feb 2026 15:43:34 +0200 Subject: [PATCH 03/14] Fix bug --- PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index d21ed42..313f5bf 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -920,11 +920,11 @@ void flushTableUpdates() stopRunningCts = watchSubscriptionCts; } - var stopRunningReg = stopRunningCts.Token.Register(stopRunningCts.Cancel); + var stopRunningReg = stopRunningCts.Token.Register(dbListenerCts.Cancel); return new ActionDisposable(() => { - stopRunningCts.Dispose(); + stopRunningReg.Dispose(); dbListenerCts.Cancel(); dbListenerCts.Dispose(); }); From 6b7a489e8dc72f27683935cd8f6eaef251f34301 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 12 Feb 2026 17:03:44 +0200 Subject: [PATCH 04/14] Finish test --- .../Client/PowerSyncDatabaseTests.cs | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index cb2d8b1..fcb3c8e 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -693,7 +693,7 @@ await db.Execute( Assert.Equal(5, callCount); } - [Fact] + [Fact(Timeout = 2000)] public async Task WatchSchemaResetTest() { var initialSchema = new Schema( @@ -711,7 +711,7 @@ public async Task WatchSchemaResetTest() new Table { Name = "assets_synced", - ViewName = "assets_inactive", + ViewName = "assets_synced_inactive", Columns = { ["make"] = ColumnType.Text, @@ -724,11 +724,10 @@ public async Task WatchSchemaResetTest() new Table { Name = "assets_local", - ViewName = "assets_inactive", + ViewName = "assets_local_inactive", Columns = { ["make"] = ColumnType.Text, - ["model"] = ColumnType.Text, ["description"] = ColumnType.Text, } }, @@ -739,7 +738,6 @@ public async Task WatchSchemaResetTest() Columns = { ["make"] = ColumnType.Text, - ["model"] = ColumnType.Text, ["description"] = ColumnType.Text, } } @@ -755,20 +753,44 @@ public async Task WatchSchemaResetTest() Schema = initialSchema }); - // TODO: Setup query watching 'assets' - // Setup semaphore to track successful watches + var sem = new SemaphoreSlim(0); + var callCount = 0; + + var query = await db.Watch("SELECT * FROM assets", [], new WatchHandler + { + OnResult = (result) => + { + Interlocked.Increment(ref callCount); + sem.Release(); + }, + OnError = error => throw error + }); + Assert.True(await sem.WaitAsync(100)); + Assert.Equal(1, callCount); + + for (int i = 0; i < 3; i++) + { + await db.Execute( + "insert into assets(id, description, make) values (?, ?, ?)", + [Guid.NewGuid().ToString(), "some desc", "some make"] + ); + Assert.True(await sem.WaitAsync(100)); + } + Assert.Equal(4, callCount); - // TODO: INSERT into 'assets' - // Verify correct count (3) + await db.UpdateSchema(updatedSchema); + Assert.True(await sem.WaitAsync(100)); + Assert.Equal(5, callCount); - // TODO: Update schema to change underlying table name without changing view name + await db.Execute("insert into assets select * from assets_local_inactive"); + Assert.True(await sem.WaitAsync(100)); + Assert.Equal(6, callCount); - // TODO: INSERT all data from "assets_local" into "assets_synced" - // Verify correct count (6) + query.Dispose(); + await Task.Delay(200); - // ** Sanity check ** - // TODO: Deregister query - // Change schema back to initial and update data - // Verify correct count (6) (unchanged) + await db.Execute("update assets set description = 'another desc'"); + Assert.False(await sem.WaitAsync(100)); + Assert.Equal(6, callCount); } } From 35a82e89e1160f342978bce015fdde7613fde8d7 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 12 Feb 2026 17:09:35 +0200 Subject: [PATCH 05/14] Test query better --- .../Client/PowerSyncDatabaseTests.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index fcb3c8e..079ef1c 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -754,19 +754,19 @@ public async Task WatchSchemaResetTest() }); var sem = new SemaphoreSlim(0); - var callCount = 0; + long lastCount = -1; - var query = await db.Watch("SELECT * FROM assets", [], new WatchHandler + var query = await db.Watch("SELECT COUNT(*) AS count FROM assets", [], new WatchHandler { OnResult = (result) => { - Interlocked.Increment(ref callCount); + lastCount = result[0].count; sem.Release(); }, OnError = error => throw error }); Assert.True(await sem.WaitAsync(100)); - Assert.Equal(1, callCount); + Assert.Equal(0, lastCount); for (int i = 0; i < 3; i++) { @@ -775,22 +775,23 @@ await db.Execute( [Guid.NewGuid().ToString(), "some desc", "some make"] ); Assert.True(await sem.WaitAsync(100)); + Assert.Equal(i + 1, lastCount); } - Assert.Equal(4, callCount); + Assert.Equal(3, lastCount); await db.UpdateSchema(updatedSchema); Assert.True(await sem.WaitAsync(100)); - Assert.Equal(5, callCount); + Assert.Equal(0, lastCount); await db.Execute("insert into assets select * from assets_local_inactive"); Assert.True(await sem.WaitAsync(100)); - Assert.Equal(6, callCount); + Assert.Equal(3, lastCount); query.Dispose(); await Task.Delay(200); - await db.Execute("update assets set description = 'another desc'"); + await db.Execute("delete from assets"); Assert.False(await sem.WaitAsync(100)); - Assert.Equal(6, callCount); + Assert.Equal(3, lastCount); } } From 0c57ef421c749415afc5339251bce27a7e09e581 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 12 Feb 2026 17:12:03 +0200 Subject: [PATCH 06/14] Cleanup, increase timeout for long running watch test --- .../PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index 079ef1c..8469fc8 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -647,7 +647,7 @@ await db.Execute( Assert.Equal(2, callCount); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 2500)] public async void WatchSingleCancelledTest() { int callCount = 0; @@ -686,8 +686,7 @@ await db.Execute( ); // Ensure nothing received from cancelled result - bool receivedResult = await semCancelled.WaitAsync(100); - Assert.False(receivedResult, "Received update after disposal"); + Assert.False(await semCancelled.WaitAsync(100)); await semAlwaysRunning.WaitAsync(); Assert.Equal(5, callCount); @@ -788,7 +787,6 @@ await db.Execute( Assert.Equal(3, lastCount); query.Dispose(); - await Task.Delay(200); await db.Execute("delete from assets"); Assert.False(await sem.WaitAsync(100)); From c50a82b971314cb4083f8028ef3f1b61434922f6 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 12 Feb 2026 17:13:02 +0200 Subject: [PATCH 07/14] Return IDisposable instead of WatchSubscription --- PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index 313f5bf..0eb3a43 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -748,7 +748,7 @@ public async Task WriteTransaction(Func> fn, DBLockO /// Use to specify the minimum interval between queries. /// Source tables are automatically detected using EXPLAIN QUERY PLAN. /// - public Task Watch(string query, object?[]? parameters, WatchHandler handler, SQLWatchOptions? options = null) + public Task Watch(string query, object?[]? parameters, WatchHandler handler, SQLWatchOptions? options = null) => Task.Run(() => WatchInternal(query, parameters, handler, options, GetAll)); /// @@ -757,10 +757,10 @@ public Task Watch(string query, object?[]? parameters, Wat /// Use to specify the minimum interval between queries. /// Source tables are automatically detected using EXPLAIN QUERY PLAN. /// - public Task Watch(string query, object?[]? parameters, WatchHandler handler, SQLWatchOptions? options = null) + public Task Watch(string query, object?[]? parameters, WatchHandler handler, SQLWatchOptions? options = null) => Task.Run(() => WatchInternal(query, parameters, handler, options, GetAll)); - private async Task WatchInternal( + private async Task WatchInternal( string query, object?[]? parameters, WatchHandler handler, From 442d155dd63217ef752c2b1bf6b4bfe7a1c23612 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 16 Feb 2026 10:04:00 +0200 Subject: [PATCH 08/14] Add check for localonly to synced switching --- .../Client/PowerSyncDatabaseTests.cs | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index 8469fc8..713870d 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -3,7 +3,7 @@ namespace PowerSync.Common.Tests.Client; using System.Diagnostics; using Microsoft.Data.Sqlite; - +using Newtonsoft.Json; using PowerSync.Common.Client; using PowerSync.Common.DB.Schema; @@ -700,6 +700,7 @@ public async Task WatchSchemaResetTest() { Name = "assets_local", ViewName = "assets", + LocalOnly = true, Columns = { ["make"] = ColumnType.Text, @@ -711,6 +712,7 @@ public async Task WatchSchemaResetTest() { Name = "assets_synced", ViewName = "assets_synced_inactive", + LocalOnly = false, Columns = { ["make"] = ColumnType.Text, @@ -724,6 +726,7 @@ public async Task WatchSchemaResetTest() { Name = "assets_local", ViewName = "assets_local_inactive", + LocalOnly = true, Columns = { ["make"] = ColumnType.Text, @@ -734,6 +737,7 @@ public async Task WatchSchemaResetTest() { Name = "assets_synced", ViewName = "assets", + LocalOnly = false, Columns = { ["make"] = ColumnType.Text, @@ -755,7 +759,8 @@ public async Task WatchSchemaResetTest() var sem = new SemaphoreSlim(0); long lastCount = -1; - var query = await db.Watch("SELECT COUNT(*) AS count FROM assets", [], new WatchHandler + string querySql = "SELECT COUNT(*) AS count FROM assets"; + var query = await db.Watch(querySql, [], new WatchHandler { OnResult = (result) => { @@ -767,6 +772,10 @@ public async Task WatchSchemaResetTest() Assert.True(await sem.WaitAsync(100)); Assert.Equal(0, lastCount); + var initialResolved = await GetSourceTables(db, querySql); + Assert.Contains("ps_data_local__assets_local", initialResolved); + Assert.DoesNotContain("ps_data__assets_synced", initialResolved); + for (int i = 0; i < 3; i++) { await db.Execute( @@ -779,6 +788,11 @@ await db.Execute( Assert.Equal(3, lastCount); await db.UpdateSchema(updatedSchema); + + var updatedResolved = await GetSourceTables(db, querySql); + Assert.Contains("ps_data__assets_synced", updatedResolved); + Assert.DoesNotContain("ps_data_local__assets_local", updatedResolved); + Assert.True(await sem.WaitAsync(100)); Assert.Equal(0, lastCount); @@ -792,4 +806,34 @@ await db.Execute( Assert.False(await sem.WaitAsync(100)); Assert.Equal(3, lastCount); } + + private class ExplainedResult + { + public int addr = 0; + public string opcode = ""; + public int p1 = 0; + public int p2 = 0; + public int p3 = 0; + public string p4 = ""; + public int p5 = 0; + } + private record TableSelectResult(string tbl_name); + private async Task> GetSourceTables(PowerSyncDatabase db, string sql, object?[]? parameters = null) + { + var explained = await db.GetAll( + $"EXPLAIN {sql}", parameters + ); + + var rootPages = explained + .Where(row => row.opcode == "OpenRead" && row.p3 == 0) + .Select(row => row.p2) + .ToList(); + + var tables = await db.GetAll( + "SELECT DISTINCT tbl_name FROM sqlite_master WHERE rootpage IN (SELECT json_each.value FROM json_each(?))", + [JsonConvert.SerializeObject(rootPages)] + ); + + return tables.Select(row => row.tbl_name).ToList(); + } } From 4767f5129f90f903c2f64a7d11742d91862c2988 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 16 Feb 2026 10:23:20 +0200 Subject: [PATCH 09/14] Reorganise test --- ; | 105 ++++++++++++++++++ .../Client/PowerSyncDatabaseTests.cs | 72 ++---------- .../PowerSync.Common.Tests/TestSchema.cs | 23 ++++ 3 files changed, 140 insertions(+), 60 deletions(-) create mode 100644 ; diff --git a/; b/; new file mode 100644 index 0000000..d399700 --- /dev/null +++ b/; @@ -0,0 +1,105 @@ +namespace PowerSync.Common.Tests; + +using PowerSync.Common.DB.Schema; + +public class TestSchemaTodoList +{ + public static Table Todos = new Table + { + Name = "todos", + Columns = + { + ["list_id"] = ColumnType.Text, + ["created_at"] = ColumnType.Text, + ["completed_at"] = ColumnType.Text, + ["description"] = ColumnType.Text, + ["created_by"] = ColumnType.Text, + ["completed_by"] = ColumnType.Text, + ["completed"] = ColumnType.Integer, + }, + Indexes = + { + ["list"] = ["list_id"] + } + }; + + public static Table Lists = new Table + { + Name = "lists", + Columns = + { + ["created_at"] = ColumnType.Text, + ["name"] = ColumnType.Text, + ["owner_id"] = ColumnType.Text, + } + }; + + public static readonly Schema AppSchema = new Schema(Todos, Lists); +} + +public class TestSchema +{ + public static readonly Dictionary AssetsColumns = new() + { + ["created_at"] = ColumnType.Text, + ["make"] = ColumnType.Text, + ["model"] = ColumnType.Text, + ["serial_number"] = ColumnType.Text, + ["quantity"] = ColumnType.Integer, + ["user_id"] = ColumnType.Text, + ["customer_id"] = ColumnType.Text, + ["description"] = ColumnType.Text, + }; + + public static readonly Table Assets = new Table + { + Name = "assets", + Columns = AssetsColumns, + Indexes = + { + ["makemodel"] = ["make", "model"] + } + }; + + public static readonly Table Customers = new Table + { + Name = "customers", + Columns = + { + ["name"] = ColumnType.Text, + ["email"] = ColumnType.Text, + } + }; + + public static readonly Schema AppSchema = new Schema(Assets, Customers); + + public static Schema GetSchemaWithCustomAssetOptions(TableOptions? assetOptions = null) + { + var customAssets = new Table("assets", AssetsColumns, assetOptions); + + return new Schema(customAssets, Customers); + } + + public static Schema MakeOptionalSyncSchema(bool synced) + { + string SyncedName(string name) => synced ? name : $"inactice_synced_{name}"; + string LocalName(string name) => synced ? $"inactive_local_{name}" : name; + + return new Schema( + new Table + { + Name = SyncedName("assets"), + Columns = AssetsColumns, + ViewName = SyncedName("assets"), + }, + new Table + { + Name = LocalName("local_assets"), + Columns = AssetsColumns, + ViewName = LocalName("assets"), + LocalOnly = true, + } + ); + } +} + diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index 713870d..b4e1759 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -3,7 +3,9 @@ namespace PowerSync.Common.Tests.Client; using System.Diagnostics; using Microsoft.Data.Sqlite; + using Newtonsoft.Json; + using PowerSync.Common.Client; using PowerSync.Common.DB.Schema; @@ -695,57 +697,6 @@ await db.Execute( [Fact(Timeout = 2000)] public async Task WatchSchemaResetTest() { - var initialSchema = new Schema( - new Table - { - Name = "assets_local", - ViewName = "assets", - LocalOnly = true, - Columns = - { - ["make"] = ColumnType.Text, - ["model"] = ColumnType.Text, - ["description"] = ColumnType.Text, - } - }, - new Table - { - Name = "assets_synced", - ViewName = "assets_synced_inactive", - LocalOnly = false, - Columns = - { - ["make"] = ColumnType.Text, - ["model"] = ColumnType.Text, - ["description"] = ColumnType.Text, - } - } - ); - var updatedSchema = new Schema( - new Table - { - Name = "assets_local", - ViewName = "assets_local_inactive", - LocalOnly = true, - Columns = - { - ["make"] = ColumnType.Text, - ["description"] = ColumnType.Text, - } - }, - new Table - { - Name = "assets_synced", - ViewName = "assets", - LocalOnly = false, - Columns = - { - ["make"] = ColumnType.Text, - ["description"] = ColumnType.Text, - } - } - ); - var dbId = Guid.NewGuid().ToString(); var db = new PowerSyncDatabase(new() { @@ -753,7 +704,7 @@ public async Task WatchSchemaResetTest() { DbFilename = $"powerSyncWatch_{dbId}.db", }, - Schema = initialSchema + Schema = TestSchema.MakeOptionalSyncSchema(false) }); var sem = new SemaphoreSlim(0); @@ -772,9 +723,9 @@ public async Task WatchSchemaResetTest() Assert.True(await sem.WaitAsync(100)); Assert.Equal(0, lastCount); - var initialResolved = await GetSourceTables(db, querySql); - Assert.Contains("ps_data_local__assets_local", initialResolved); - Assert.DoesNotContain("ps_data__assets_synced", initialResolved); + var resolved = await GetSourceTables(db, querySql); + Assert.Single(resolved); + Assert.Contains("ps_data_local__local_assets", resolved); for (int i = 0; i < 3; i++) { @@ -787,19 +738,20 @@ await db.Execute( } Assert.Equal(3, lastCount); - await db.UpdateSchema(updatedSchema); + await db.UpdateSchema(TestSchema.MakeOptionalSyncSchema(true)); - var updatedResolved = await GetSourceTables(db, querySql); - Assert.Contains("ps_data__assets_synced", updatedResolved); - Assert.DoesNotContain("ps_data_local__assets_local", updatedResolved); + resolved = await GetSourceTables(db, querySql); + Assert.Single(resolved); + Assert.Contains("ps_data__assets", resolved); Assert.True(await sem.WaitAsync(100)); Assert.Equal(0, lastCount); - await db.Execute("insert into assets select * from assets_local_inactive"); + await db.Execute("insert into assets select * from inactive_local_assets"); Assert.True(await sem.WaitAsync(100)); Assert.Equal(3, lastCount); + // Sanity check query.Dispose(); await db.Execute("delete from assets"); diff --git a/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs b/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs index 55b9a97..8812534 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs @@ -79,4 +79,27 @@ public static Schema GetSchemaWithCustomAssetOptions(TableOptions? assetOptions return new Schema(customAssets, Customers); } + + public static Schema MakeOptionalSyncSchema(bool synced) + { + string SyncedName(string name) => synced ? name : $"inactice_synced_{name}"; + string LocalName(string name) => synced ? $"inactive_local_{name}" : name; + + return new Schema( + new Table + { + Name = "assets", + Columns = AssetsColumns, + ViewName = SyncedName("assets"), + }, + new Table + { + Name = "local_assets", + Columns = AssetsColumns, + ViewName = LocalName("assets"), + LocalOnly = true, + } + ); + } } + From 04c73f6858892a124e8b37b361204a6357cff25b Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 16 Feb 2026 10:25:01 +0200 Subject: [PATCH 10/14] Changelog --- PowerSync/PowerSync.Common/CHANGELOG.md | 4 ++++ PowerSync/PowerSync.Maui/CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index 5e01c47..766022e 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -1,5 +1,9 @@ # PowerSync.Common Changelog +## 0.0.10-alpha.1 + +- Fix watched queries sometimes resolving to the wrong underlying tables after a schema change. + ## 0.0.9-alpha.1 - _Breaking:_ Further updated schema definition syntax. diff --git a/PowerSync/PowerSync.Maui/CHANGELOG.md b/PowerSync/PowerSync.Maui/CHANGELOG.md index f903a1d..3ca510c 100644 --- a/PowerSync/PowerSync.Maui/CHANGELOG.md +++ b/PowerSync/PowerSync.Maui/CHANGELOG.md @@ -1,5 +1,9 @@ # PowerSync.Maui Changelog +## 0.0.8-alpha.1 + +- Upstream PowerSync.Common version bump (See Powersync.Common changelog 0.0.10-alpha.1 for more information) + ## 0.0.7-alpha.1 - Upstream PowerSync.Common version bump (See Powersync.Common changelog 0.0.9-alpha.1 for more information) From 323fd82fd294f8761850c78afe06b84efbef8359 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 16 Feb 2026 10:27:49 +0200 Subject: [PATCH 11/14] More changelog --- PowerSync/PowerSync.Common/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index 766022e..4f675e3 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.0.10-alpha.1 - Fix watched queries sometimes resolving to the wrong underlying tables after a schema change. +- Fix some properties in Table not being public when they are meant to be. ## 0.0.9-alpha.1 From e01bc07765d2489df99a59190b393856acc7bac1 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 16 Feb 2026 10:30:23 +0200 Subject: [PATCH 12/14] Remove random file called ';' AGAIN (thanks again neovim) --- ; | 105 -------------------------------------------------------------- 1 file changed, 105 deletions(-) delete mode 100644 ; diff --git a/; b/; deleted file mode 100644 index d399700..0000000 --- a/; +++ /dev/null @@ -1,105 +0,0 @@ -namespace PowerSync.Common.Tests; - -using PowerSync.Common.DB.Schema; - -public class TestSchemaTodoList -{ - public static Table Todos = new Table - { - Name = "todos", - Columns = - { - ["list_id"] = ColumnType.Text, - ["created_at"] = ColumnType.Text, - ["completed_at"] = ColumnType.Text, - ["description"] = ColumnType.Text, - ["created_by"] = ColumnType.Text, - ["completed_by"] = ColumnType.Text, - ["completed"] = ColumnType.Integer, - }, - Indexes = - { - ["list"] = ["list_id"] - } - }; - - public static Table Lists = new Table - { - Name = "lists", - Columns = - { - ["created_at"] = ColumnType.Text, - ["name"] = ColumnType.Text, - ["owner_id"] = ColumnType.Text, - } - }; - - public static readonly Schema AppSchema = new Schema(Todos, Lists); -} - -public class TestSchema -{ - public static readonly Dictionary AssetsColumns = new() - { - ["created_at"] = ColumnType.Text, - ["make"] = ColumnType.Text, - ["model"] = ColumnType.Text, - ["serial_number"] = ColumnType.Text, - ["quantity"] = ColumnType.Integer, - ["user_id"] = ColumnType.Text, - ["customer_id"] = ColumnType.Text, - ["description"] = ColumnType.Text, - }; - - public static readonly Table Assets = new Table - { - Name = "assets", - Columns = AssetsColumns, - Indexes = - { - ["makemodel"] = ["make", "model"] - } - }; - - public static readonly Table Customers = new Table - { - Name = "customers", - Columns = - { - ["name"] = ColumnType.Text, - ["email"] = ColumnType.Text, - } - }; - - public static readonly Schema AppSchema = new Schema(Assets, Customers); - - public static Schema GetSchemaWithCustomAssetOptions(TableOptions? assetOptions = null) - { - var customAssets = new Table("assets", AssetsColumns, assetOptions); - - return new Schema(customAssets, Customers); - } - - public static Schema MakeOptionalSyncSchema(bool synced) - { - string SyncedName(string name) => synced ? name : $"inactice_synced_{name}"; - string LocalName(string name) => synced ? $"inactive_local_{name}" : name; - - return new Schema( - new Table - { - Name = SyncedName("assets"), - Columns = AssetsColumns, - ViewName = SyncedName("assets"), - }, - new Table - { - Name = LocalName("local_assets"), - Columns = AssetsColumns, - ViewName = LocalName("assets"), - LocalOnly = true, - } - ); - } -} - From 5e4d44c3923159fdc8202183282bde3e0d8a1eab Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 16 Feb 2026 10:39:37 +0200 Subject: [PATCH 13/14] Increase timeouts on tests --- .../Client/PowerSyncDatabaseTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index b4e1759..99b0fda 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -649,7 +649,7 @@ await db.Execute( Assert.Equal(2, callCount); } - [Fact(Timeout = 2500)] + [Fact(Timeout = 3000)] public async void WatchSingleCancelledTest() { int callCount = 0; @@ -688,13 +688,13 @@ await db.Execute( ); // Ensure nothing received from cancelled result - Assert.False(await semCancelled.WaitAsync(100)); + Assert.False(await semCancelled.WaitAsync(200)); await semAlwaysRunning.WaitAsync(); Assert.Equal(5, callCount); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 3000)] public async Task WatchSchemaResetTest() { var dbId = Guid.NewGuid().ToString(); @@ -720,7 +720,7 @@ public async Task WatchSchemaResetTest() }, OnError = error => throw error }); - Assert.True(await sem.WaitAsync(100)); + Assert.True(await sem.WaitAsync(200)); Assert.Equal(0, lastCount); var resolved = await GetSourceTables(db, querySql); @@ -733,7 +733,7 @@ await db.Execute( "insert into assets(id, description, make) values (?, ?, ?)", [Guid.NewGuid().ToString(), "some desc", "some make"] ); - Assert.True(await sem.WaitAsync(100)); + Assert.True(await sem.WaitAsync(200)); Assert.Equal(i + 1, lastCount); } Assert.Equal(3, lastCount); @@ -744,18 +744,18 @@ await db.Execute( Assert.Single(resolved); Assert.Contains("ps_data__assets", resolved); - Assert.True(await sem.WaitAsync(100)); + Assert.True(await sem.WaitAsync(200)); Assert.Equal(0, lastCount); await db.Execute("insert into assets select * from inactive_local_assets"); - Assert.True(await sem.WaitAsync(100)); + Assert.True(await sem.WaitAsync(200)); Assert.Equal(3, lastCount); // Sanity check query.Dispose(); await db.Execute("delete from assets"); - Assert.False(await sem.WaitAsync(100)); + Assert.False(await sem.WaitAsync(200)); Assert.Equal(3, lastCount); } From 3f784f44354f0bd72bf3352094c39bfbd1d3133d Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 16 Feb 2026 11:20:37 +0200 Subject: [PATCH 14/14] Remove conflict marker --- PowerSync/PowerSync.Common/CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index 78e93ff..05e329a 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -42,8 +42,6 @@ public class Schema var todos = powerSync.GetAll("SELECT * FROM todos"); ``` -> > > > > > > main - ## 0.0.9-alpha.1 - _Breaking:_ Further updated schema definition syntax.