Skip to content

Commit 80f5af8

Browse files
authored
Support loading custom SQLite extensions (#72)
1 parent 9ad5885 commit 80f5af8

7 files changed

Lines changed: 171 additions & 48 deletions

File tree

PowerSync/PowerSync.Common/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# PowerSync.Common Changelog
22

3-
## 0.1.3 (unreleased)
3+
## 0.1.3
44

5+
- Add support for loading custom SQLite extensions via `MDSQLiteOptions.Extensions`.
56
- Fix streaming sync retry loop reconnecting with no delay after an exception, ignoring `RetryDelayMs`.
67

78
## 0.1.2

PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ private RequiredMDSQLiteOptions ResolveMDSQLiteOptions(MDSQLiteOptions? options)
6060
LockTimeoutMs = options?.LockTimeoutMs ?? defaults.LockTimeoutMs,
6161
EncryptionKey = options?.EncryptionKey ?? defaults.EncryptionKey,
6262
Extensions = options?.Extensions ?? defaults.Extensions,
63+
LoadPowerSyncExtension = options?.LoadPowerSyncExtension ?? defaults.LoadPowerSyncExtension,
6364
ReadPoolSize = options?.ReadPoolSize ?? defaults.ReadPoolSize,
6465
};
6566
}
@@ -105,7 +106,7 @@ private async Task Init()
105106
}
106107
return readConnection;
107108
};
108-
readPool = new MDSQLiteConnectionPool(resolvedOptions, readConnectionFactory);
109+
readPool = new MDSQLiteConnectionPool(resolvedOptions.ReadPoolSize, readConnectionFactory);
109110
await readPool.Init();
110111

111112
// Register TablesUpdated listener
@@ -125,10 +126,25 @@ private async Task Init()
125126
protected async Task<MDSQLiteConnection> OpenConnection(string dbFilename)
126127
{
127128
var db = OpenDatabase(dbFilename);
128-
LoadExtension(db);
129+
LoadExtensions(db);
129130

130131
var connection = new MDSQLiteConnection(new MDSQLiteConnectionOptions(db));
131-
await connection.Execute("SELECT powersync_init()");
132+
try
133+
{
134+
await connection.Execute("SELECT powersync_init()");
135+
}
136+
catch (SqliteException ex)
137+
{
138+
// SQLite will throw a very unhelpful "SQLite Error 1: 'The specified
139+
// module could not be found.'" error if uncaught.
140+
throw new SqliteException(
141+
"Failed to initialize PowerSync: powersync_init() is not registered. " +
142+
"Ensure the PowerSync core SQLite extension is loaded. Either set " +
143+
"MDSQLiteOptions.LoadPowerSyncExtension to true (default), or supply " +
144+
"a PowerSync-compatible extension via MDSQLiteOptions.Extensions.",
145+
ex.SqliteErrorCode,
146+
ex.SqliteExtendedErrorCode);
147+
}
132148

133149
return connection;
134150
}
@@ -141,11 +157,28 @@ private static SqliteConnection OpenDatabase(string dbFilename)
141157
return connection;
142158
}
143159

144-
protected virtual void LoadExtension(SqliteConnection db)
160+
protected virtual void LoadExtensions(SqliteConnection db)
145161
{
146-
string extensionPath = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory);
147162
db.EnableExtensions(true);
148-
db.LoadExtension(extensionPath, "sqlite3_powersync_init");
163+
if (resolvedOptions.LoadPowerSyncExtension)
164+
{
165+
LoadDefaultPowerSyncExtension(db);
166+
}
167+
foreach (var extension in resolvedOptions.Extensions)
168+
{
169+
db.LoadExtension(extension.Path, extension.EntryPoint);
170+
}
171+
}
172+
173+
/// <summary>
174+
/// Loads the bundled PowerSync core SQLite extension. Override on
175+
/// platform-specific adapters (e.g. MAUI iOS/Android) where the native library
176+
/// lives outside the desktop runtime path.
177+
/// </summary>
178+
protected virtual void LoadDefaultPowerSyncExtension(SqliteConnection db)
179+
{
180+
var path = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory);
181+
db.LoadExtension(path, "sqlite3_powersync_init");
149182
}
150183

151184
public async Task Close()
@@ -301,18 +334,16 @@ await readPool.LeaseAll(async (connections) =>
301334

302335
class MDSQLiteConnectionPool
303336
{
304-
private readonly RequiredMDSQLiteOptions _options;
305337
private readonly Channel<MDSQLiteConnection> _channel;
306338
private readonly int _poolSize;
307339
private readonly Func<Task<MDSQLiteConnection>> _connectionFactory;
308340

309341
private readonly Task _initialized;
310342

311-
public MDSQLiteConnectionPool(RequiredMDSQLiteOptions options, Func<Task<MDSQLiteConnection>> connectionFactory)
343+
public MDSQLiteConnectionPool(int poolSize, Func<Task<MDSQLiteConnection>> connectionFactory)
312344
{
313-
_options = options;
314-
_channel = Channel.CreateBounded<MDSQLiteConnection>(options.ReadPoolSize);
315-
_poolSize = options.ReadPoolSize;
345+
_channel = Channel.CreateBounded<MDSQLiteConnection>(poolSize);
346+
_poolSize = poolSize;
316347
_connectionFactory = connectionFactory;
317348
_initialized = Initialize();
318349
}
@@ -364,32 +395,6 @@ public async Task LeaseAll(Func<List<MDSQLiteConnection>, Task> callback)
364395
}
365396
}
366397

367-
private async Task<MDSQLiteConnection> OpenConnection(string dbFilename)
368-
{
369-
var db = OpenDatabase(dbFilename);
370-
LoadExtension(db);
371-
372-
var connection = new MDSQLiteConnection(new MDSQLiteConnectionOptions(db));
373-
await connection.Execute("SELECT powersync_init()");
374-
375-
return connection;
376-
}
377-
378-
private static SqliteConnection OpenDatabase(string dbFilename)
379-
{
380-
string connectionString = $"Data Source={dbFilename};Pooling=False;";
381-
var connection = new SqliteConnection(connectionString);
382-
connection.Open();
383-
return connection;
384-
}
385-
386-
private void LoadExtension(SqliteConnection db)
387-
{
388-
string extensionPath = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory);
389-
db.EnableExtensions(true);
390-
db.LoadExtension(extensionPath, "sqlite3_powersync_init");
391-
}
392-
393398
public async Task Close()
394399
{
395400
await LeaseAll((connections) =>

PowerSync/PowerSync.Common/MDSQLite/MDSQLiteOptions.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,23 @@ public class MDSQLiteOptions
9999
public int? CacheSizeKb { get; set; }
100100

101101
/// <summary>
102-
/// Load extensions using the path and entryPoint.
102+
/// Additional SQLite extensions to load on every connection, in order. Defaults
103+
/// to an empty list. The bundled PowerSync core extension is loaded separately
104+
/// and controlled by <see cref="LoadPowerSyncExtension"/> — do not include it
105+
/// here.
103106
/// </summary>
104107
public SqliteExtension[]? Extensions { get; set; }
105108

109+
/// <summary>
110+
/// Whether to load the bundled PowerSync core SQLite extension on every
111+
/// connection. Defaults to true and should remain true for normal use — the
112+
/// rest of the library relies on the SQL functions and virtual tables it
113+
/// registers (e.g. <c>powersync_init()</c>). Set to false only if you are
114+
/// supplying an equivalent PowerSync-compatible extension via
115+
/// <see cref="Extensions"/>.
116+
/// </summary>
117+
public bool? LoadPowerSyncExtension { get; set; }
118+
106119
/// <summary>
107120
/// The number of MDSQLiteConnection objects to create for the read pool.
108121
/// </summary>
@@ -121,6 +134,7 @@ public class RequiredMDSQLiteOptions : MDSQLiteOptions
121134
LockTimeoutMs = 30000,
122135
EncryptionKey = null,
123136
Extensions = [],
137+
LoadPowerSyncExtension = true,
124138
ReadPoolSize = 5,
125139
};
126140

@@ -140,5 +154,7 @@ public class RequiredMDSQLiteOptions : MDSQLiteOptions
140154

141155
public new SqliteExtension[] Extensions { get; set; } = null!;
142156

157+
public new bool LoadPowerSyncExtension { get; set; }
158+
143159
public new int ReadPoolSize { get; set; }
144160
}

PowerSync/PowerSync.Maui/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PowerSync.Maui Changelog
22

3-
## 0.1.3 (unreleased)
3+
## 0.1.3
44

55
- Upstream PowerSync.Common version bump (See Powersync.Common changelog 0.1.3 for more information)
66

PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,24 @@ public MAUISQLiteAdapter(MDSQLiteAdapterOptions options) : base(options)
1515
{
1616
}
1717

18-
protected override void LoadExtension(SqliteConnection db)
18+
// The bundled PowerSync extension lives in a platform-specific location on
19+
// iOS/MacCatalyst/Android — the desktop runtime path used by the base class
20+
// does not resolve to it. Override only the PowerSync-extension load hook;
21+
// user-supplied custom extensions still flow through MDSQLiteAdapter.LoadExtensions
22+
// unchanged, so consumers can freely combine the bundled extension (via the
23+
// LoadPowerSyncExtension flag) with their own.
24+
protected override void LoadDefaultPowerSyncExtension(SqliteConnection db)
1925
{
20-
db.EnableExtensions(true);
21-
2226
#if IOS || MACCATALYST
2327
LoadExtensionApple(db);
2428
#elif ANDROID
2529
db.LoadExtension("libpowersync");
2630
#else
27-
base.LoadExtension(db);
31+
base.LoadDefaultPowerSyncExtension(db);
2832
#endif
2933
}
3034

31-
private void LoadExtensionApple(SqliteConnection db)
35+
private static void LoadExtensionApple(SqliteConnection db)
3236
{
3337
#if IOS || MACCATALYST
3438
var bundlePath = Foundation.NSBundle.FromIdentifier("co.powersync.sqlitecore")?.BundlePath;

Tests/PowerSync/PowerSync.Common.Tests/EventStreamTests.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,24 +217,25 @@ public async Task EventManager_ShouldNotReceiveEventsAfterDeregistering()
217217

218218
var cts = new CancellationTokenSource();
219219
var listener = stream.ListenAsync(cts.Token);
220+
var tcs = new TaskCompletionSource<bool>();
220221
var sem = new SemaphoreSlim(0);
221222
int eventCount = 0;
222223

223224
_ = Task.Run(async () =>
224225
{
225-
sem.Release();
226+
tcs.SetResult(true);
226227
await foreach (var evt in listener)
227228
{
228229
eventCount++;
229230
sem.Release();
230231
}
231232
}, cts.Token);
232-
Assert.True(await sem.WaitAsync(100));
233+
await tcs.Task;
233234

234235
Assert.True(manager.Deregister<string>());
235236

236237
Assert.False(manager.TryEmit("invalid"));
237-
Assert.False(await sem.WaitAsync(100));
238+
Assert.False(await sem.WaitAsync(500));
238239

239240
// Cleanup
240241
cts.Cancel();
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
namespace PowerSync.Common.Tests.MDSQLite;
2+
3+
using Microsoft.Data.Sqlite;
4+
5+
using PowerSync.Common.Client;
6+
using PowerSync.Common.MDSQLite;
7+
using PowerSync.Common.Tests.Utils;
8+
using PowerSync.Common.Utils;
9+
10+
/// <summary>
11+
/// dotnet test -v n --framework net8.0 --filter "MDSQLiteAdapterTests"
12+
/// </summary>
13+
[Collection("MDSQLiteAdapterTests")]
14+
public class MDSQLiteAdapterTests
15+
{
16+
private class AssetResult
17+
{
18+
public string id { get; set; } = "";
19+
public string description { get; set; } = "";
20+
public string? make { get; set; }
21+
}
22+
23+
[Fact]
24+
public async Task DisablingCoreExtensionPreventsPowerSyncFromLoading()
25+
{
26+
var dbName = $"MDSQLiteAdapter-{Guid.NewGuid():N}.db";
27+
var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions
28+
{
29+
Database = new MDSQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions
30+
{
31+
DbFilename = dbName,
32+
SqliteOptions = new MDSQLiteOptions
33+
{
34+
LoadPowerSyncExtension = false,
35+
Extensions = [],
36+
},
37+
}),
38+
Schema = TestSchema.AppSchema,
39+
});
40+
41+
try
42+
{
43+
// Without the PowerSync extension, `powersync_init()` is not a registered function.
44+
await Assert.ThrowsAsync<SqliteException>(async () => await db.Init());
45+
}
46+
finally
47+
{
48+
try { await db.Close(); } catch { /* expected — init failed */ }
49+
DatabaseUtils.CleanDb(dbName);
50+
}
51+
}
52+
53+
[Fact]
54+
public async Task LoadsCustomPowerSyncExtensionFromOverriddenPath()
55+
{
56+
var dbName = $"MDSQLiteAdapter-{Guid.NewGuid():N}.db";
57+
var sourcePath = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory);
58+
var customPath = Path.Combine(
59+
Path.GetTempPath(),
60+
$"powersync-ext-copy-{Guid.NewGuid():N}{Path.GetExtension(sourcePath)}"
61+
);
62+
File.Copy(sourcePath, customPath, overwrite: true);
63+
64+
var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions
65+
{
66+
Database = new MDSQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions
67+
{
68+
DbFilename = dbName,
69+
SqliteOptions = new MDSQLiteOptions
70+
{
71+
LoadPowerSyncExtension = false,
72+
Extensions = [
73+
new SqliteExtension { Path = customPath, EntryPoint = "sqlite3_powersync_init" },
74+
],
75+
},
76+
}),
77+
Schema = TestSchema.AppSchema,
78+
});
79+
80+
try
81+
{
82+
await db.Init();
83+
84+
var id = await TestUtils.InsertRandomAsset(db);
85+
var rows = await db.GetAll<AssetResult>("SELECT id, description, make FROM assets");
86+
Assert.Single(rows);
87+
Assert.Equal(id, rows[0].id);
88+
}
89+
finally
90+
{
91+
await db.Close();
92+
DatabaseUtils.CleanDb(dbName);
93+
try { File.Delete(customPath); } catch { /* best-effort cleanup */ }
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)