diff --git a/.changeset/happy-baboons-fold.md b/.changeset/happy-baboons-fold.md new file mode 100644 index 000000000..939731fed --- /dev/null +++ b/.changeset/happy-baboons-fold.md @@ -0,0 +1,5 @@ +--- +'@powersync/capacitor': patch +--- + +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. diff --git a/packages/capacitor/README.md b/packages/capacitor/README.md index 94efc59ef..e622714c3 100644 --- a/packages/capacitor/README.md +++ b/packages/capacitor/README.md @@ -61,6 +61,8 @@ const db = new PowerSyncDatabase({ - On Android and iOS, this SDK uses [Capacitor Community SQLite](https://github.com/capacitor-community/sqlite) for native database access. - On web, it falls back to the [PowerSync Web SDK](https://www.npmjs.com/package/@powersync/web). +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. + When using custom database factories, be sure to specify the `CapacitorSQLiteOpenFactory` for Capacitor platforms. ```javascript diff --git a/packages/capacitor/src/PowerSyncDatabase.ts b/packages/capacitor/src/PowerSyncDatabase.ts index 7de450358..d9e5a53cf 100644 --- a/packages/capacitor/src/PowerSyncDatabase.ts +++ b/packages/capacitor/src/PowerSyncDatabase.ts @@ -1,19 +1,21 @@ import { Capacitor } from '@capacitor/core'; import { - BucketStorageAdapter, DBAdapter, + DEFAULT_STREAM_CONNECTION_OPTIONS, MEMORY_TRIGGER_CLAIM_MANAGER, PowerSyncBackendConnector, + PowerSyncConnectionOptions, RequiredAdditionalConnectionOptions, StreamingSyncImplementation, + SyncStreamConnectionMethod, TriggerManagerConfig, PowerSyncDatabase as WebPowerSyncDatabase, - WebPowerSyncDatabaseOptionsWithSettings, - WebRemote + WebPowerSyncDatabaseOptionsWithSettings } from '@powersync/web'; import { CapacitorSQLiteAdapter } from './adapter/CapacitorSQLiteAdapter.js'; -import { CapacitorBucketStorageAdapter } from './sync/CapacitorBucketStorageAdapter.js'; +import { CapacitorRemote } from './sync/CapacitorRemote.js'; import { CapacitorStreamingSyncImplementation } from './sync/CapacitorSyncImplementation.js'; + /** * PowerSyncDatabase class for managing database connections and sync implementations. * This extends the WebPowerSyncDatabase to provide platform-specific implementations @@ -23,6 +25,29 @@ import { CapacitorStreamingSyncImplementation } from './sync/CapacitorSyncImplem * @alpha */ export class PowerSyncDatabase extends WebPowerSyncDatabase { + /** + * Connects to stream of events from the PowerSync instance. + * {@link PowerSyncConnectionOptions#connectionMethod} defaults to WebSocket connection on Web platforms + * or HTTP connections if using {@link CapacitorSQLiteAdapter} - this is due to poor performance with + * the Capacitor Community SQLite library and binary payloads. + */ + connect(connector: PowerSyncBackendConnector, options?: PowerSyncConnectionOptions): Promise { + const isUsingCapacitorDriver = this.database instanceof CapacitorSQLiteAdapter; + const defaultConnectionMethod = isUsingCapacitorDriver + ? SyncStreamConnectionMethod.HTTP + : DEFAULT_STREAM_CONNECTION_OPTIONS.connectionMethod; + if (options?.connectionMethod == SyncStreamConnectionMethod.WEB_SOCKET && isUsingCapacitorDriver) { + this.logger.warn( + `Connecting via 'SyncStreamConnectionMethod.WEB_SOCKET' when using the 'CapacitorSQLiteAdapter' will result in poor sync performance. Use 'SyncStreamConnectionMethod.HTTP' (the default for native) instead.` + ); + } + + return super.connect(connector, { + ...(options ?? {}), + connectionMethod: options?.connectionMethod ?? defaultConnectionMethod + }); + } + protected get isNativeCapacitorPlatform(): boolean { const platform = Capacitor.getPlatform(); return platform == 'ios' || platform == 'android'; @@ -70,14 +95,6 @@ export class PowerSyncDatabase extends WebPowerSyncDatabase { } } - protected generateBucketStorageAdapter(): BucketStorageAdapter { - if (this.isNativeCapacitorPlatform) { - return new CapacitorBucketStorageAdapter(this.database, this.logger); - } else { - return super.generateBucketStorageAdapter(); - } - } - protected generateSyncStreamImplementation( connector: PowerSyncBackendConnector, options: RequiredAdditionalConnectionOptions @@ -89,7 +106,8 @@ export class PowerSyncDatabase extends WebPowerSyncDatabase { if (this.options.flags?.enableMultiTabs) { this.logger.warn(`enableMultiTabs is not supported on Capacitor mobile platforms. Ignoring the flag.`); } - const remote = new WebRemote(connector, this.logger); + + const remote = new CapacitorRemote(connector, this.logger); return new CapacitorStreamingSyncImplementation({ ...(this.options as {}), diff --git a/packages/capacitor/src/adapter/CapacitorSQLiteAdapter.ts b/packages/capacitor/src/adapter/CapacitorSQLiteAdapter.ts index fe40fe915..08d47fe3c 100644 --- a/packages/capacitor/src/adapter/CapacitorSQLiteAdapter.ts +++ b/packages/capacitor/src/adapter/CapacitorSQLiteAdapter.ts @@ -12,8 +12,7 @@ import { LockContext, Mutex, QueryResult, - timeoutSignal, - Transaction + timeoutSignal } from '@powersync/web'; import { PowerSyncCore } from '../plugin/PowerSyncCore.js'; import { messageForErrorCode } from '../plugin/PowerSyncPlugin.js'; @@ -33,6 +32,46 @@ async function monitorQuery(sql: string, executor: () => Promise): } } +/** + * Maps SQLite query parameter values to Capacitor Community supported formats. + * This handles binary payloads for both iOS and Android. + */ +function mapSQLiteParameterValues({ platform, values }: { platform: string; values: any[] }) { + return values.map((value) => { + if (value instanceof Uint8Array) { + switch (platform) { + case 'ios': { + /** + * The Buffer polyfill, used in @powersync/common, is a Uint8Array subclass which defines additional fields like + * `_isBuffer` and `parent` on its `prototype`. The additional fields are serialized when passed through the native bridge. + * The Capacitor Community SQLite library expects a dictionary of indexes to numerical bytes. + * The additional fields (which are not an index to numerical byte mapping) cause the parsing logic in the SQLite library to throw an error: + * "Error in reading buffer". + * + * Re-wrapping the same backing buffer as a plain Uint8Array removes the Buffer subclass wrapper + * while keeping the same underlying bytes. This creates a new view, not a byte copy, so the + * overhead should be minimal. + */ + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + case 'android': { + /** + * Android expects an object of the form: + * { type: 'Buffer', data: [...]} + */ + return { + type: 'Buffer', + data: Array.from(value) + }; + } + } + } + + // return value as-is + return value; + }); +} + class CapacitorConnectionPool extends BaseObserver implements ConnectionPool { protected _writeConnection: SQLiteDBConnection | null; protected _readConnection: SQLiteDBConnection | null; @@ -119,7 +158,11 @@ class CapacitorConnectionPool extends BaseObserver implements protected generateLockContext(db: SQLiteDBConnection): LockContext { const _query = async (query: string, params: any[] = []) => { - const result = await db.query(query, params); + const mappedParams = mapSQLiteParameterValues({ + platform: Capacitor.getPlatform(), + values: params + }); + const result = await db.query(query, mappedParams); const arrayResult = result.values ?? []; return { rowsAffected: 0, @@ -134,31 +177,35 @@ class CapacitorConnectionPool extends BaseObserver implements const _execute = async (query: string, params: any[] = []): Promise => { const platform = Capacitor.getPlatform(); - if (db.getConnectionReadOnly()) { + if ( + db.getConnectionReadOnly() || + // Android: use query for SELECT and executeSet for mutations + // We cannot use `run` here for both cases. + (platform == 'android' && query.toLowerCase().trim().startsWith('select')) + ) { return _query(query, params); } + const mappedParams = mapSQLiteParameterValues({ + platform, + values: params + }); + if (platform == 'android') { - // Android: use query for SELECT and executeSet for mutations - // We cannot use `run` here for both cases. - if (query.toLowerCase().trim().startsWith('select')) { - return _query(query, params); - } else { - const result = await db.executeSet([{ statement: query, values: params }], false); - return { - insertId: result.changes?.lastId, - rowsAffected: result.changes?.changes ?? 0, - rows: { - _array: [], - length: 0, - item: () => null - } - }; - } + const result = await db.executeSet([{ statement: query, values: mappedParams }], false); + return { + insertId: result.changes?.lastId, + rowsAffected: result.changes?.changes ?? 0, + rows: { + _array: [], + length: 0, + item: () => null + } + }; } // iOS (and other platforms): use run("all") - const result = await db.run(query, params, false, 'all'); + const result = await db.run(query, mappedParams, false, 'all'); const resultSet = result.changes?.values ?? []; return { insertId: result.changes?.lastId, @@ -204,10 +251,14 @@ class CapacitorConnectionPool extends BaseObserver implements }; const executeBatch = async (query: string, params: any[][] = []): Promise => { + const platform = Capacitor.getPlatform(); let result = await db.executeSet( params.map((param) => ({ statement: query, - values: param + values: mapSQLiteParameterValues({ + platform, + values: param + }) })) ); diff --git a/packages/capacitor/src/sync/CapacitorBucketStorageAdapter.ts b/packages/capacitor/src/sync/CapacitorBucketStorageAdapter.ts deleted file mode 100644 index ab9926fcb..000000000 --- a/packages/capacitor/src/sync/CapacitorBucketStorageAdapter.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { PowerSyncControlCommand, SqliteBucketStorage } from '@powersync/web'; - -export class CapacitorBucketStorageAdapter extends SqliteBucketStorage { - control(op: PowerSyncControlCommand, payload: string | Uint8Array | ArrayBuffer | null): Promise { - if (payload instanceof Uint8Array && (payload as any)['_isBuffer'] == true) { - /** - * The Buffer polyfill, used in @powersync/common, is a Uint8Array subclass which defines additional fields like - * `_isBuffer` and `parent` on its `prototype`. The additional fields are serialized when passed through the native bridge. - * The Capacitor Community SQLite lib expects a dictionarty of indexes to numerical bytes. - * The additiona fields (which are not an index to numerical byte mapping) cause the parsing logic in the SQLite lib to throw an error: - * "Error in reading buffer". - * - * Re-wrapping the same backing buffer as a plain Uint8Array removes the Buffer subclass wrapper - * while keeping the same underlying bytes. This creates a new view, not a byte copy, so the - * overhead should be minimal. - */ - payload = new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength); - } - - return super.control(op, payload); - } -} diff --git a/packages/capacitor/src/sync/CapacitorRemote.ts b/packages/capacitor/src/sync/CapacitorRemote.ts new file mode 100644 index 000000000..16b0a906b --- /dev/null +++ b/packages/capacitor/src/sync/CapacitorRemote.ts @@ -0,0 +1,23 @@ +import { WebRemote } from '@powersync/web'; + +export class CapacitorRemote extends WebRemote { + protected get supportsStreamingBinaryResponses(): boolean { + /** + * We'd like to avoid passing Binary buffers to SQLite when using + * iOS and Android for now. This is due to inefficient binary processing. + * Syncing using Buffers and Capacitor Community SQLite has been observed to be notably + * slower than the NDJSON option. + * Capacitor Community SQLite serializes Buffer objects, which causes slowdown + * ios: https://github.com/capacitor-community/sqlite/blob/f507a1e779688ea72b9d7e8744c647f7b688c568/ios/Plugin/CapacitorSQLite.swift#L888-L912 + * android: https://github.com/capacitor-community/sqlite/blob/master/android/src/main/java/com/getcapacitor/community/database/sqlite/SQLite/UtilsSQLite.java#L141-L147 + * As a rough guideline, the time to locally sync 10_000 small records was observed as: + * iOS: + * - NDJSON: 449ms + * - Binary: 68_982ms + * Android: + * - NDJSON: 452ms + * - Binary: 1_847ms + */ + return false; + } +}