Skip to content

Commit a656afa

Browse files
fix: [Capacitor] use HTTP NDJSON streaming as default (#940)
1 parent 6b4122a commit a656afa

6 files changed

Lines changed: 134 additions & 57 deletions

File tree

.changeset/happy-baboons-fold.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/capacitor': patch
3+
---
4+
5+
The `PowerSyncDatabase.connect` method now defaults to using NDJSON-HTTP as the connection method when using the Capacitor Community SQLite driver. This avoids slow binary processing present in Capacitor Community SQLite and should significantly increase sync performance on native platforms.

packages/capacitor/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ const db = new PowerSyncDatabase({
6161
- On Android and iOS, this SDK uses [Capacitor Community SQLite](https://github.com/capacitor-community/sqlite) for native database access.
6262
- On web, it falls back to the [PowerSync Web SDK](https://www.npmjs.com/package/@powersync/web).
6363

64+
When using the native Capacitor Community SQLite driver, `PowerSyncDatabase.connect()` defaults to HTTP with NDJSON streaming. This avoids slow binary payload processing in the native SQLite bridge. Web targets keep the default Web SDK connection behavior.
65+
6466
When using custom database factories, be sure to specify the `CapacitorSQLiteOpenFactory` for Capacitor platforms.
6567

6668
```javascript

packages/capacitor/src/PowerSyncDatabase.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import { Capacitor } from '@capacitor/core';
22
import {
3-
BucketStorageAdapter,
43
DBAdapter,
4+
DEFAULT_STREAM_CONNECTION_OPTIONS,
55
MEMORY_TRIGGER_CLAIM_MANAGER,
66
PowerSyncBackendConnector,
7+
PowerSyncConnectionOptions,
78
RequiredAdditionalConnectionOptions,
89
StreamingSyncImplementation,
10+
SyncStreamConnectionMethod,
911
TriggerManagerConfig,
1012
PowerSyncDatabase as WebPowerSyncDatabase,
11-
WebPowerSyncDatabaseOptionsWithSettings,
12-
WebRemote
13+
WebPowerSyncDatabaseOptionsWithSettings
1314
} from '@powersync/web';
1415
import { CapacitorSQLiteAdapter } from './adapter/CapacitorSQLiteAdapter.js';
15-
import { CapacitorBucketStorageAdapter } from './sync/CapacitorBucketStorageAdapter.js';
16+
import { CapacitorRemote } from './sync/CapacitorRemote.js';
1617
import { CapacitorStreamingSyncImplementation } from './sync/CapacitorSyncImplementation.js';
18+
1719
/**
1820
* PowerSyncDatabase class for managing database connections and sync implementations.
1921
* This extends the WebPowerSyncDatabase to provide platform-specific implementations
@@ -23,6 +25,29 @@ import { CapacitorStreamingSyncImplementation } from './sync/CapacitorSyncImplem
2325
* @alpha
2426
*/
2527
export class PowerSyncDatabase extends WebPowerSyncDatabase {
28+
/**
29+
* Connects to stream of events from the PowerSync instance.
30+
* {@link PowerSyncConnectionOptions#connectionMethod} defaults to WebSocket connection on Web platforms
31+
* or HTTP connections if using {@link CapacitorSQLiteAdapter} - this is due to poor performance with
32+
* the Capacitor Community SQLite library and binary payloads.
33+
*/
34+
connect(connector: PowerSyncBackendConnector, options?: PowerSyncConnectionOptions): Promise<void> {
35+
const isUsingCapacitorDriver = this.database instanceof CapacitorSQLiteAdapter;
36+
const defaultConnectionMethod = isUsingCapacitorDriver
37+
? SyncStreamConnectionMethod.HTTP
38+
: DEFAULT_STREAM_CONNECTION_OPTIONS.connectionMethod;
39+
if (options?.connectionMethod == SyncStreamConnectionMethod.WEB_SOCKET && isUsingCapacitorDriver) {
40+
this.logger.warn(
41+
`Connecting via 'SyncStreamConnectionMethod.WEB_SOCKET' when using the 'CapacitorSQLiteAdapter' will result in poor sync performance. Use 'SyncStreamConnectionMethod.HTTP' (the default for native) instead.`
42+
);
43+
}
44+
45+
return super.connect(connector, {
46+
...(options ?? {}),
47+
connectionMethod: options?.connectionMethod ?? defaultConnectionMethod
48+
});
49+
}
50+
2651
protected get isNativeCapacitorPlatform(): boolean {
2752
const platform = Capacitor.getPlatform();
2853
return platform == 'ios' || platform == 'android';
@@ -70,14 +95,6 @@ export class PowerSyncDatabase extends WebPowerSyncDatabase {
7095
}
7196
}
7297

73-
protected generateBucketStorageAdapter(): BucketStorageAdapter {
74-
if (this.isNativeCapacitorPlatform) {
75-
return new CapacitorBucketStorageAdapter(this.database, this.logger);
76-
} else {
77-
return super.generateBucketStorageAdapter();
78-
}
79-
}
80-
8198
protected generateSyncStreamImplementation(
8299
connector: PowerSyncBackendConnector,
83100
options: RequiredAdditionalConnectionOptions
@@ -89,7 +106,8 @@ export class PowerSyncDatabase extends WebPowerSyncDatabase {
89106
if (this.options.flags?.enableMultiTabs) {
90107
this.logger.warn(`enableMultiTabs is not supported on Capacitor mobile platforms. Ignoring the flag.`);
91108
}
92-
const remote = new WebRemote(connector, this.logger);
109+
110+
const remote = new CapacitorRemote(connector, this.logger);
93111

94112
return new CapacitorStreamingSyncImplementation({
95113
...(this.options as {}),

packages/capacitor/src/adapter/CapacitorSQLiteAdapter.ts

Lines changed: 73 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ import {
1212
LockContext,
1313
Mutex,
1414
QueryResult,
15-
timeoutSignal,
16-
Transaction
15+
timeoutSignal
1716
} from '@powersync/web';
1817
import { PowerSyncCore } from '../plugin/PowerSyncCore.js';
1918
import { messageForErrorCode } from '../plugin/PowerSyncPlugin.js';
@@ -33,6 +32,46 @@ async function monitorQuery(sql: string, executor: () => Promise<QueryResult>):
3332
}
3433
}
3534

35+
/**
36+
* Maps SQLite query parameter values to Capacitor Community supported formats.
37+
* This handles binary payloads for both iOS and Android.
38+
*/
39+
function mapSQLiteParameterValues({ platform, values }: { platform: string; values: any[] }) {
40+
return values.map((value) => {
41+
if (value instanceof Uint8Array) {
42+
switch (platform) {
43+
case 'ios': {
44+
/**
45+
* The Buffer polyfill, used in @powersync/common, is a Uint8Array subclass which defines additional fields like
46+
* `_isBuffer` and `parent` on its `prototype`. The additional fields are serialized when passed through the native bridge.
47+
* The Capacitor Community SQLite library expects a dictionary of indexes to numerical bytes.
48+
* The additional fields (which are not an index to numerical byte mapping) cause the parsing logic in the SQLite library to throw an error:
49+
* "Error in reading buffer".
50+
*
51+
* Re-wrapping the same backing buffer as a plain Uint8Array removes the Buffer subclass wrapper
52+
* while keeping the same underlying bytes. This creates a new view, not a byte copy, so the
53+
* overhead should be minimal.
54+
*/
55+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
56+
}
57+
case 'android': {
58+
/**
59+
* Android expects an object of the form:
60+
* { type: 'Buffer', data: [...]}
61+
*/
62+
return {
63+
type: 'Buffer',
64+
data: Array.from(value)
65+
};
66+
}
67+
}
68+
}
69+
70+
// return value as-is
71+
return value;
72+
});
73+
}
74+
3675
class CapacitorConnectionPool extends BaseObserver<DBAdapterListener> implements ConnectionPool {
3776
protected _writeConnection: SQLiteDBConnection | null;
3877
protected _readConnection: SQLiteDBConnection | null;
@@ -119,7 +158,11 @@ class CapacitorConnectionPool extends BaseObserver<DBAdapterListener> implements
119158

120159
protected generateLockContext(db: SQLiteDBConnection): LockContext {
121160
const _query = async (query: string, params: any[] = []) => {
122-
const result = await db.query(query, params);
161+
const mappedParams = mapSQLiteParameterValues({
162+
platform: Capacitor.getPlatform(),
163+
values: params
164+
});
165+
const result = await db.query(query, mappedParams);
123166
const arrayResult = result.values ?? [];
124167
return {
125168
rowsAffected: 0,
@@ -134,31 +177,35 @@ class CapacitorConnectionPool extends BaseObserver<DBAdapterListener> implements
134177
const _execute = async (query: string, params: any[] = []): Promise<QueryResult> => {
135178
const platform = Capacitor.getPlatform();
136179

137-
if (db.getConnectionReadOnly()) {
180+
if (
181+
db.getConnectionReadOnly() ||
182+
// Android: use query for SELECT and executeSet for mutations
183+
// We cannot use `run` here for both cases.
184+
(platform == 'android' && query.toLowerCase().trim().startsWith('select'))
185+
) {
138186
return _query(query, params);
139187
}
140188

189+
const mappedParams = mapSQLiteParameterValues({
190+
platform,
191+
values: params
192+
});
193+
141194
if (platform == 'android') {
142-
// Android: use query for SELECT and executeSet for mutations
143-
// We cannot use `run` here for both cases.
144-
if (query.toLowerCase().trim().startsWith('select')) {
145-
return _query(query, params);
146-
} else {
147-
const result = await db.executeSet([{ statement: query, values: params }], false);
148-
return {
149-
insertId: result.changes?.lastId,
150-
rowsAffected: result.changes?.changes ?? 0,
151-
rows: {
152-
_array: [],
153-
length: 0,
154-
item: () => null
155-
}
156-
};
157-
}
195+
const result = await db.executeSet([{ statement: query, values: mappedParams }], false);
196+
return {
197+
insertId: result.changes?.lastId,
198+
rowsAffected: result.changes?.changes ?? 0,
199+
rows: {
200+
_array: [],
201+
length: 0,
202+
item: () => null
203+
}
204+
};
158205
}
159206

160207
// iOS (and other platforms): use run("all")
161-
const result = await db.run(query, params, false, 'all');
208+
const result = await db.run(query, mappedParams, false, 'all');
162209
const resultSet = result.changes?.values ?? [];
163210
return {
164211
insertId: result.changes?.lastId,
@@ -204,10 +251,14 @@ class CapacitorConnectionPool extends BaseObserver<DBAdapterListener> implements
204251
};
205252

206253
const executeBatch = async (query: string, params: any[][] = []): Promise<QueryResult> => {
254+
const platform = Capacitor.getPlatform();
207255
let result = await db.executeSet(
208256
params.map((param) => ({
209257
statement: query,
210-
values: param
258+
values: mapSQLiteParameterValues({
259+
platform,
260+
values: param
261+
})
211262
}))
212263
);
213264

packages/capacitor/src/sync/CapacitorBucketStorageAdapter.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { WebRemote } from '@powersync/web';
2+
3+
export class CapacitorRemote extends WebRemote {
4+
protected get supportsStreamingBinaryResponses(): boolean {
5+
/**
6+
* We'd like to avoid passing Binary buffers to SQLite when using
7+
* iOS and Android for now. This is due to inefficient binary processing.
8+
* Syncing using Buffers and Capacitor Community SQLite has been observed to be notably
9+
* slower than the NDJSON option.
10+
* Capacitor Community SQLite serializes Buffer objects, which causes slowdown
11+
* ios: https://github.com/capacitor-community/sqlite/blob/f507a1e779688ea72b9d7e8744c647f7b688c568/ios/Plugin/CapacitorSQLite.swift#L888-L912
12+
* android: https://github.com/capacitor-community/sqlite/blob/master/android/src/main/java/com/getcapacitor/community/database/sqlite/SQLite/UtilsSQLite.java#L141-L147
13+
* As a rough guideline, the time to locally sync 10_000 small records was observed as:
14+
* iOS:
15+
* - NDJSON: 449ms
16+
* - Binary: 68_982ms
17+
* Android:
18+
* - NDJSON: 452ms
19+
* - Binary: 1_847ms
20+
*/
21+
return false;
22+
}
23+
}

0 commit comments

Comments
 (0)