Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wobbly-octopi-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/capacitor': patch
---

Normalize binary SQLite parameters for Capacitor iOS so `Uint8Array` sync payloads can be passed through `powersync_control(...)` without hitting `Error in reading buffer`.
13 changes: 7 additions & 6 deletions packages/capacitor/src/adapter/CapacitorSQLiteAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { PowerSyncCore } from '../plugin/PowerSyncCore.js';
import { messageForErrorCode } from '../plugin/PowerSyncPlugin.js';
import { CapacitorSQLiteOpenFactoryOptions, DEFAULT_SQLITE_OPTIONS } from './CapacitorSQLiteOpenFactory.js';
import { normalizeIOSSqliteParams } from './sqliteParams.js';
/**
* Monitors the execution time of a query and logs it to the performance timeline.
*/
Expand All @@ -39,13 +40,15 @@ class CapacitorConnectionPool extends BaseObserver<DBAdapterListener> implements
protected initializedPromise: Promise<void>;
protected writeMutex: Mutex;
protected readMutex: Mutex;
protected readonly platform: string;

constructor(protected options: CapacitorSQLiteOpenFactoryOptions) {
super();
this._writeConnection = null;
this._readConnection = null;
this.writeMutex = new Mutex();
this.readMutex = new Mutex();
this.platform = Capacitor.getPlatform();
this.initializedPromise = this.init();
}

Expand Down Expand Up @@ -98,8 +101,7 @@ class CapacitorConnectionPool extends BaseObserver<DBAdapterListener> implements

await this._readConnection.open();

const platform = Capacitor.getPlatform();
if (platform == 'android') {
if (this.platform == 'android') {
/**
* SQLCipher for Android enables dynamic loading of extensions.
* On iOS we use a static auto extension registration.
Expand Down Expand Up @@ -132,13 +134,11 @@ class CapacitorConnectionPool extends BaseObserver<DBAdapterListener> implements
};

const _execute = async (query: string, params: any[] = []): Promise<QueryResult> => {
const platform = Capacitor.getPlatform();

if (db.getConnectionReadOnly()) {
return _query(query, params);
}

if (platform == 'android') {
if (this.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')) {
Expand All @@ -158,7 +158,8 @@ class CapacitorConnectionPool extends BaseObserver<DBAdapterListener> implements
}

// iOS (and other platforms): use run("all")
const result = await db.run(query, params, false, 'all');
const sqliteParams = this.platform == 'ios' ? normalizeIOSSqliteParams(params) : params;
const result = await db.run(query, sqliteParams, false, 'all');
const resultSet = result.changes?.values ?? [];
return {
insertId: result.changes?.lastId,
Expand Down
33 changes: 33 additions & 0 deletions packages/capacitor/src/adapter/sqliteParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type IOSSqliteBufferParam = Record<string, number>;

export type NormalizedIOSSqliteParam = unknown | IOSSqliteBufferParam;

export function normalizeIOSSqliteParams(params: unknown[]): NormalizedIOSSqliteParam[] {
return params.map((param) => normalizeIOSSqliteParam(param));
}

function normalizeIOSSqliteParam(value: unknown): NormalizedIOSSqliteParam {
if (value instanceof Uint8Array) {
return uint8ArrayToIOSBuffer(value);
}

if (value instanceof ArrayBuffer) {
return uint8ArrayToIOSBuffer(new Uint8Array(value));
}

if (ArrayBuffer.isView(value)) {
return uint8ArrayToIOSBuffer(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
}

return value;
}

function uint8ArrayToIOSBuffer(array: Uint8Array): IOSSqliteBufferParam {
Copy link
Copy Markdown
Contributor

@simolus3 simolus3 Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution! It's annoying that this workaround is required, but it is what it is.

This feels so absurdly inefficient that I think we should look into not using line_binary on Capacitor at all. Or maybe we need a hack to encode the blob as hex and then decode it with unhex(?) in SQLite. That's still inefficient and I think we should only do it on platforms where we need it.

Out of curiosity, does this really work on Android or did you just test iOS? Anyway, can we return a regular JSON array here (e.g. return [...array])? That should be slightly more efficient, but it's still not great.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: [...array], I think that'd skip the dict-based buffer path in CapacitorSQLite.swift entirely. The Record<string, number> builds the shape the Swift side expects. Haven't tested Android. Agree on unhex(?) longer-term.

// The Capacitor SQLite iOS bridge expects BLOB params as an index-keyed object
// with integer values. It does not accept typed arrays directly.
const result: IOSSqliteBufferParam = {};
for (let i = 0; i < array.length; i++) {
result[String(i)] = array[i];
}
return result;
}