diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index f786650..7d8ec10 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -1,5 +1,9 @@ # PowerSync.Common Changelog +## 0.1.3-dev.1 + +- Add support for loading custom SQLite extensions via `MDSQLiteOptions.Extensions`. + ## 0.1.2 - Add support for MacCatalyst. diff --git a/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs b/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs index 0b83a2d..5ae03cf 100644 --- a/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs +++ b/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs @@ -60,6 +60,7 @@ private RequiredMDSQLiteOptions ResolveMDSQLiteOptions(MDSQLiteOptions? options) LockTimeoutMs = options?.LockTimeoutMs ?? defaults.LockTimeoutMs, EncryptionKey = options?.EncryptionKey ?? defaults.EncryptionKey, Extensions = options?.Extensions ?? defaults.Extensions, + LoadPowerSyncExtension = options?.LoadPowerSyncExtension ?? defaults.LoadPowerSyncExtension, ReadPoolSize = options?.ReadPoolSize ?? defaults.ReadPoolSize, }; } @@ -105,7 +106,7 @@ private async Task Init() } return readConnection; }; - readPool = new MDSQLiteConnectionPool(resolvedOptions, readConnectionFactory); + readPool = new MDSQLiteConnectionPool(resolvedOptions.ReadPoolSize, readConnectionFactory); await readPool.Init(); // Register TablesUpdated listener @@ -125,10 +126,25 @@ private async Task Init() protected async Task OpenConnection(string dbFilename) { var db = OpenDatabase(dbFilename); - LoadExtension(db); + LoadExtensions(db); var connection = new MDSQLiteConnection(new MDSQLiteConnectionOptions(db)); - await connection.Execute("SELECT powersync_init()"); + try + { + await connection.Execute("SELECT powersync_init()"); + } + catch (SqliteException ex) + { + // SQLite will throw a very unhelpful "SQLite Error 1: 'The specified + // module could not be found.'" error if uncaught. + throw new SqliteException( + "Failed to initialize PowerSync: powersync_init() is not registered. " + + "Ensure the PowerSync core SQLite extension is loaded. Either set " + + "MDSQLiteOptions.LoadPowerSyncExtension to true (default), or supply " + + "a PowerSync-compatible extension via MDSQLiteOptions.Extensions.", + ex.SqliteErrorCode, + ex.SqliteExtendedErrorCode); + } return connection; } @@ -141,11 +157,28 @@ private static SqliteConnection OpenDatabase(string dbFilename) return connection; } - protected virtual void LoadExtension(SqliteConnection db) + protected virtual void LoadExtensions(SqliteConnection db) { - string extensionPath = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); db.EnableExtensions(true); - db.LoadExtension(extensionPath, "sqlite3_powersync_init"); + if (resolvedOptions.LoadPowerSyncExtension) + { + LoadDefaultPowerSyncExtension(db); + } + foreach (var extension in resolvedOptions.Extensions) + { + db.LoadExtension(extension.Path, extension.EntryPoint); + } + } + + /// + /// 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. + /// + protected virtual void LoadDefaultPowerSyncExtension(SqliteConnection db) + { + var path = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); + db.LoadExtension(path, "sqlite3_powersync_init"); } public async Task Close() @@ -301,18 +334,16 @@ await readPool.LeaseAll(async (connections) => class MDSQLiteConnectionPool { - private readonly RequiredMDSQLiteOptions _options; private readonly Channel _channel; private readonly int _poolSize; private readonly Func> _connectionFactory; private readonly Task _initialized; - public MDSQLiteConnectionPool(RequiredMDSQLiteOptions options, Func> connectionFactory) + public MDSQLiteConnectionPool(int poolSize, Func> connectionFactory) { - _options = options; - _channel = Channel.CreateBounded(options.ReadPoolSize); - _poolSize = options.ReadPoolSize; + _channel = Channel.CreateBounded(poolSize); + _poolSize = poolSize; _connectionFactory = connectionFactory; _initialized = Initialize(); } @@ -364,32 +395,6 @@ public async Task LeaseAll(Func, Task> callback) } } - private async Task OpenConnection(string dbFilename) - { - var db = OpenDatabase(dbFilename); - LoadExtension(db); - - var connection = new MDSQLiteConnection(new MDSQLiteConnectionOptions(db)); - await connection.Execute("SELECT powersync_init()"); - - return connection; - } - - private static SqliteConnection OpenDatabase(string dbFilename) - { - string connectionString = $"Data Source={dbFilename};Pooling=False;"; - var connection = new SqliteConnection(connectionString); - connection.Open(); - return connection; - } - - private void LoadExtension(SqliteConnection db) - { - string extensionPath = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); - db.EnableExtensions(true); - db.LoadExtension(extensionPath, "sqlite3_powersync_init"); - } - public async Task Close() { await LeaseAll((connections) => diff --git a/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteOptions.cs b/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteOptions.cs index 845b57c..5ccf11b 100644 --- a/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteOptions.cs +++ b/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteOptions.cs @@ -99,10 +99,23 @@ public class MDSQLiteOptions public int? CacheSizeKb { get; set; } /// - /// Load extensions using the path and entryPoint. + /// Additional SQLite extensions to load on every connection, in order. Defaults + /// to an empty list. The bundled PowerSync core extension is loaded separately + /// and controlled by — do not include it + /// here. /// public SqliteExtension[]? Extensions { get; set; } + /// + /// Whether to load the bundled PowerSync core SQLite extension on every + /// connection. Defaults to true and should remain true for normal use — the + /// rest of the library relies on the SQL functions and virtual tables it + /// registers (e.g. powersync_init()). Set to false only if you are + /// supplying an equivalent PowerSync-compatible extension via + /// . + /// + public bool? LoadPowerSyncExtension { get; set; } + /// /// The number of MDSQLiteConnection objects to create for the read pool. /// @@ -121,6 +134,7 @@ public class RequiredMDSQLiteOptions : MDSQLiteOptions LockTimeoutMs = 30000, EncryptionKey = null, Extensions = [], + LoadPowerSyncExtension = true, ReadPoolSize = 5, }; @@ -140,5 +154,7 @@ public class RequiredMDSQLiteOptions : MDSQLiteOptions public new SqliteExtension[] Extensions { get; set; } = null!; + public new bool LoadPowerSyncExtension { get; set; } + public new int ReadPoolSize { get; set; } } diff --git a/PowerSync/PowerSync.Maui/CHANGELOG.md b/PowerSync/PowerSync.Maui/CHANGELOG.md index 79b9c7b..26160a5 100644 --- a/PowerSync/PowerSync.Maui/CHANGELOG.md +++ b/PowerSync/PowerSync.Maui/CHANGELOG.md @@ -1,5 +1,9 @@ # PowerSync.Maui Changelog +## 0.1.3-dev.1 + +- Upstream PowerSync.Common version bump (See Powersync.Common changelog 0.1.3 for more information) + ## 0.1.2 - Add support for MacCatalyst. diff --git a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs b/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs index 68a6b25..0d6e399 100644 --- a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs +++ b/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs @@ -15,20 +15,24 @@ public MAUISQLiteAdapter(MDSQLiteAdapterOptions options) : base(options) { } - protected override void LoadExtension(SqliteConnection db) + // 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) { - db.EnableExtensions(true); - #if IOS || MACCATALYST LoadExtensionApple(db); #elif ANDROID db.LoadExtension("libpowersync"); #else - base.LoadExtension(db); + base.LoadDefaultPowerSyncExtension(db); #endif } - private void LoadExtensionApple(SqliteConnection db) + private static void LoadExtensionApple(SqliteConnection db) { #if IOS || MACCATALYST var bundlePath = Foundation.NSBundle.FromIdentifier("co.powersync.sqlitecore")?.BundlePath; diff --git a/Tests/PowerSync/PowerSync.Common.Tests/EventStreamTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/EventStreamTests.cs index 657f40b..777c668 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/EventStreamTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/EventStreamTests.cs @@ -217,24 +217,25 @@ public async Task EventManager_ShouldNotReceiveEventsAfterDeregistering() var cts = new CancellationTokenSource(); var listener = stream.ListenAsync(cts.Token); + var tcs = new TaskCompletionSource(); var sem = new SemaphoreSlim(0); int eventCount = 0; _ = Task.Run(async () => { - sem.Release(); + tcs.SetResult(true); await foreach (var evt in listener) { eventCount++; sem.Release(); } }, cts.Token); - Assert.True(await sem.WaitAsync(100)); + await tcs.Task; Assert.True(manager.Deregister()); Assert.False(manager.TryEmit("invalid")); - Assert.False(await sem.WaitAsync(100)); + Assert.False(await sem.WaitAsync(500)); // Cleanup cts.Cancel(); diff --git a/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs new file mode 100644 index 0000000..8789b7b --- /dev/null +++ b/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs @@ -0,0 +1,96 @@ +namespace PowerSync.Common.Tests.MDSQLite; + +using Microsoft.Data.Sqlite; + +using PowerSync.Common.Client; +using PowerSync.Common.MDSQLite; +using PowerSync.Common.Tests.Utils; +using PowerSync.Common.Utils; + +/// +/// dotnet test -v n --framework net8.0 --filter "MDSQLiteAdapterTests" +/// +[Collection("MDSQLiteAdapterTests")] +public class MDSQLiteAdapterTests +{ + private class AssetResult + { + public string id { get; set; } = ""; + public string description { get; set; } = ""; + public string? make { get; set; } + } + + [Fact] + public async Task DisablingCoreExtensionPreventsPowerSyncFromLoading() + { + var dbName = $"MDSQLiteAdapter-{Guid.NewGuid():N}.db"; + var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions + { + Database = new MDSQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions + { + DbFilename = dbName, + SqliteOptions = new MDSQLiteOptions + { + LoadPowerSyncExtension = false, + Extensions = [], + }, + }), + Schema = TestSchema.AppSchema, + }); + + try + { + // Without the PowerSync extension, `powersync_init()` is not a registered function. + await Assert.ThrowsAsync(async () => await db.Init()); + } + finally + { + try { await db.Close(); } catch { /* expected — init failed */ } + DatabaseUtils.CleanDb(dbName); + } + } + + [Fact] + public async Task LoadsCustomPowerSyncExtensionFromOverriddenPath() + { + var dbName = $"MDSQLiteAdapter-{Guid.NewGuid():N}.db"; + var sourcePath = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); + var customPath = Path.Combine( + Path.GetTempPath(), + $"powersync-ext-copy-{Guid.NewGuid():N}{Path.GetExtension(sourcePath)}" + ); + File.Copy(sourcePath, customPath, overwrite: true); + + var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions + { + Database = new MDSQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions + { + DbFilename = dbName, + SqliteOptions = new MDSQLiteOptions + { + LoadPowerSyncExtension = false, + Extensions = [ + new SqliteExtension { Path = customPath, EntryPoint = "sqlite3_powersync_init" }, + ], + }, + }), + Schema = TestSchema.AppSchema, + }); + + try + { + await db.Init(); + + var id = await TestUtils.InsertRandomAsset(db); + var rows = await db.GetAll("SELECT id, description, make FROM assets"); + Assert.Single(rows); + Assert.Equal(id, rows[0].id); + } + finally + { + await db.Close(); + DatabaseUtils.CleanDb(dbName); + try { File.Delete(customPath); } catch { /* best-effort cleanup */ } + } + } +}