-
Notifications
You must be signed in to change notification settings - Fork 176
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(client): capacitor-sqlite driver (#484)
Compatibility with [capacitor-community/sqlite](https://github.com/capacitor-community/sqlite) via a new adapter and accompanying plumbing and tests.
- Loading branch information
Showing
10 changed files
with
533 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"electric-sql": minor | ||
--- | ||
|
||
New DB driver for capacitor-sqlite. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
clients/typescript/src/drivers/capacitor-sqlite/adapter.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
import { capSQLiteSet } from '@capacitor-community/sqlite' | ||
import { | ||
DatabaseAdapter as DatabaseAdapterInterface, | ||
RunResult, | ||
TableNameImpl, | ||
Transaction as Tx, | ||
} from '../../electric/adapter' | ||
import { Row, SqlValue, Statement } from '../../util' | ||
import { Database } from './database' | ||
import { Mutex } from 'async-mutex' | ||
|
||
export class DatabaseAdapter | ||
extends TableNameImpl | ||
implements DatabaseAdapterInterface | ||
{ | ||
#txMutex: Mutex | ||
|
||
constructor(public db: Database) { | ||
super() | ||
this.#txMutex = new Mutex() | ||
} | ||
|
||
async run({ sql, args }: Statement): Promise<RunResult> { | ||
if (args && !Array.isArray(args)) { | ||
throw new Error( | ||
`capacitor-sqlite doesn't support named query parameters, use positional parameters instead` | ||
) | ||
} | ||
|
||
const wrapInTransaction = false // Default is true. electric calls run from within transaction<T> so we need to disable transactions here. | ||
|
||
const result = await this.db.run(sql, args, wrapInTransaction) | ||
const rowsAffected = result.changes?.changes ?? 0 | ||
return { rowsAffected } | ||
} | ||
|
||
async runInTransaction(...statements: Statement[]): Promise<RunResult> { | ||
if (statements.some((x) => x.args && !Array.isArray(x.args))) { | ||
throw new Error( | ||
`capacitor-sqlite doesn't support named query parameters, use positional parameters instead` | ||
) | ||
} | ||
|
||
const set: capSQLiteSet[] = statements.map(({ sql, args }) => ({ | ||
statement: sql, | ||
values: (args ?? []) as SqlValue[], | ||
})) | ||
|
||
const wrapInTransaction = true | ||
const result = await this.db.executeSet(set, wrapInTransaction) | ||
const rowsAffected = result.changes?.changes ?? 0 | ||
// TODO: unsure how capacitor-sqlite populates the changes value (additive?), and what is expected of electric here. | ||
return { rowsAffected } | ||
} | ||
|
||
async query({ sql, args }: Statement): Promise<Row[]> { | ||
if (args && !Array.isArray(args)) { | ||
throw new Error( | ||
`capacitor-sqlite doesn't support named query parameters, use positional parameters instead` | ||
) | ||
} | ||
const result = await this.db.query(sql, args) | ||
return result.values ?? [] | ||
} | ||
|
||
// No async await on capacitor-sqlite promise-based APIs + the complexity of the transaction<T> API make for one ugly implementation... | ||
async transaction<T>( | ||
f: (_tx: Tx, setResult: (res: T) => void) => void | ||
): Promise<T> { | ||
// Acquire mutex before even instantiating the transaction object. | ||
// This will ensure transactions cannot get interleaved. | ||
const releaseMutex = await this.#txMutex.acquire() | ||
return new Promise<T>((resolve, reject) => { | ||
// Convenience function. Rejecting should always release the acquired mutex. | ||
const releaseMutexAndReject = (err?: any) => { | ||
releaseMutex() | ||
reject(err) | ||
} | ||
|
||
this.db | ||
.beginTransaction() | ||
.then(() => { | ||
const wrappedTx = new WrappedTx(this) | ||
try { | ||
f(wrappedTx, (res) => { | ||
// Client calls this setResult function when done. Commit and resolve. | ||
this.db | ||
.commitTransaction() | ||
.then(() => { | ||
releaseMutex() | ||
resolve(res) | ||
}) | ||
.catch((err) => releaseMutexAndReject(err)) | ||
}) | ||
} catch (err) { | ||
this.db | ||
.rollbackTransaction() | ||
.then(() => { | ||
releaseMutexAndReject(err) | ||
}) | ||
.catch((err) => releaseMutexAndReject(err)) | ||
} | ||
}) | ||
.catch((err) => releaseMutexAndReject(err)) // Are all those catch -> rejects needed? Apparently, yes because of explicit promises. Tests confirm this. | ||
}) | ||
} | ||
} | ||
|
||
// Did consider handling begin/commit/rollback transaction in this wrapper, but in the end it made more sense | ||
// to do so within the transaction<T> implementation, promises bubble up naturally that way and no need for inTransaction flag. | ||
class WrappedTx implements Tx { | ||
constructor(private adapter: DatabaseAdapter) {} | ||
|
||
run( | ||
statement: Statement, | ||
successCallback?: (tx: Tx, res: RunResult) => void, | ||
errorCallback?: (error: any) => void | ||
): void { | ||
this.adapter | ||
.run(statement) | ||
.then((runResult) => { | ||
if (typeof successCallback !== 'undefined') { | ||
successCallback(this, runResult) | ||
} | ||
}) | ||
.catch((err) => { | ||
if (typeof errorCallback !== 'undefined') { | ||
errorCallback(err) | ||
} | ||
}) | ||
} | ||
|
||
query( | ||
statement: Statement, | ||
successCallback: (tx: Tx, res: Row[]) => void, | ||
errorCallback?: (error: any) => void | ||
): void { | ||
this.adapter | ||
.query(statement) | ||
.then((result) => { | ||
if (typeof successCallback !== 'undefined') { | ||
successCallback(this, result) | ||
} | ||
}) | ||
.catch((err) => { | ||
if (typeof errorCallback !== 'undefined') { | ||
errorCallback(err) | ||
} | ||
}) | ||
} | ||
} |
22 changes: 22 additions & 0 deletions
22
clients/typescript/src/drivers/capacitor-sqlite/database.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import { SQLiteDBConnection } from '@capacitor-community/sqlite' | ||
import { DbName } from '../../util/types' | ||
|
||
// A bit of a hack, but that lets us reference the actual types of the library | ||
// TODO: Is this the type we want to expose? | ||
type OriginalDatabase = SQLiteDBConnection | ||
|
||
// The relevant subset of the SQLitePlugin database client API | ||
// that we need to ensure the client we're electrifying provides. | ||
// TODO: verify which functions we actually need. | ||
export interface Database | ||
extends Pick< | ||
OriginalDatabase, | ||
| 'executeSet' | ||
| 'query' | ||
| 'run' | ||
| 'beginTransaction' | ||
| 'commitTransaction' | ||
| 'rollbackTransaction' | ||
> { | ||
dbname?: DbName | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
// N.b.: importing this module is an entrypoint that imports the Capacitor | ||
// environment dependencies. You can | ||
// use the alternative entrypoint in `./test` to avoid importing this. | ||
import { DbName } from '../../util/types' | ||
|
||
import { | ||
ElectrifyOptions, | ||
electrify as baseElectrify, | ||
} from '../../electric/index' | ||
|
||
import { DatabaseAdapter } from './adapter' | ||
import { ElectricConfig } from '../../config' | ||
import { Database } from './database' | ||
import { MockSocket } from '../../sockets/mock' | ||
import { ElectricClient } from '../../client/model/client' | ||
import { DbSchema } from '../../client/model/schema' | ||
|
||
export { DatabaseAdapter } | ||
export type { Database } | ||
|
||
export const electrify = async <T extends Database, DB extends DbSchema<any>>( | ||
db: T, | ||
dbDescription: DB, | ||
config: ElectricConfig, | ||
opts?: ElectrifyOptions | ||
): Promise<ElectricClient<DB>> => { | ||
const dbName: DbName = db.dbname! | ||
const adapter = opts?.adapter || new DatabaseAdapter(db) | ||
const socketFactory = opts?.socketFactory || MockSocket | ||
|
||
const namespace = await baseElectrify( | ||
dbName, | ||
dbDescription, | ||
adapter, | ||
socketFactory, | ||
config, | ||
opts | ||
) | ||
|
||
return namespace | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { capSQLiteChanges, DBSQLiteValues } from '@capacitor-community/sqlite' | ||
import { DbName } from '../../util/types' | ||
import { Database } from './database' | ||
|
||
export class MockDatabase implements Database { | ||
constructor(public dbname: DbName, public fail?: Error) {} | ||
|
||
executeSet(): Promise<capSQLiteChanges> { | ||
return this.resolveIfNotFail({ changes: { changes: 0 } }) | ||
} | ||
|
||
query(): Promise<DBSQLiteValues> { | ||
return this.resolveIfNotFail({ | ||
values: [ | ||
{ textColumn: 'text1', numberColumn: 1 }, | ||
{ textColumn: 'text2', numberColumn: 2 }, | ||
], | ||
}) | ||
} | ||
|
||
run(): Promise<capSQLiteChanges> { | ||
return this.resolveIfNotFail({ changes: { changes: 0 } }) | ||
} | ||
beginTransaction(): Promise<capSQLiteChanges> { | ||
return this.resolveIfNotFail({ changes: { changes: 0 } }) | ||
} | ||
commitTransaction(): Promise<capSQLiteChanges> { | ||
return this.resolveIfNotFail({ changes: { changes: 0 } }) | ||
} | ||
rollbackTransaction(): Promise<capSQLiteChanges> { | ||
return this.resolveIfNotFail({ changes: { changes: 0 } }) | ||
} | ||
|
||
private resolveIfNotFail<T>(value: T): Promise<T> { | ||
if (typeof this.fail !== 'undefined') return Promise.reject(this.fail) | ||
else return Promise.resolve(value) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
// Safe entrypoint for tests that avoids importing the Capacitor | ||
// specific dependencies. | ||
import { DbName } from '../../util/types' | ||
|
||
import { electrify, ElectrifyOptions } from '../../electric/index' | ||
|
||
import { MockMigrator } from '../../migrators/mock' | ||
import { Notifier } from '../../notifiers/index' | ||
import { MockNotifier } from '../../notifiers/mock' | ||
import { MockRegistry } from '../../satellite/mock' | ||
|
||
import { DatabaseAdapter } from './adapter' | ||
import { Database } from './database' | ||
import { MockDatabase } from './mock' | ||
import { MockSocket } from '../../sockets/mock' | ||
import { ElectricClient } from '../../client/model/client' | ||
import { ElectricConfig } from '../../config' | ||
import { DbSchema } from '../../client/model' | ||
|
||
const testConfig = { | ||
auth: { | ||
token: 'test-token', | ||
}, | ||
} | ||
|
||
type RetVal<DB extends DbSchema<any>, N extends Notifier> = Promise< | ||
[Database, N, ElectricClient<DB>] | ||
> | ||
|
||
export const initTestable = async < | ||
DB extends DbSchema<any>, | ||
N extends Notifier = MockNotifier | ||
>( | ||
dbName: DbName, | ||
dbDescription: DB, | ||
config: ElectricConfig = testConfig, | ||
opts?: ElectrifyOptions | ||
): RetVal<DB, N> => { | ||
const db = new MockDatabase(dbName) | ||
|
||
const adapter = opts?.adapter || new DatabaseAdapter(db) | ||
const notifier = (opts?.notifier as N) || new MockNotifier(dbName) | ||
const migrator = opts?.migrator || new MockMigrator() | ||
const socketFactory = opts?.socketFactory || MockSocket | ||
const registry = opts?.registry || new MockRegistry() | ||
|
||
const dal = await electrify( | ||
dbName, | ||
dbDescription, | ||
adapter, | ||
socketFactory, | ||
config, | ||
{ | ||
notifier: notifier, | ||
migrator: migrator, | ||
registry: registry, | ||
} | ||
) | ||
|
||
return [db, notifier, dal] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.