Skip to content

Commit

Permalink
feat(client): capacitor-sqlite driver (#484)
Browse files Browse the repository at this point in the history
Compatibility with [capacitor-community/sqlite](https://github.com/capacitor-community/sqlite) via a new adapter and accompanying plumbing and tests.
  • Loading branch information
gregzo authored Oct 5, 2023
1 parent 2b24fa2 commit 3d98c1f
Show file tree
Hide file tree
Showing 10 changed files with 533 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/odd-falcons-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"electric-sql": minor
---

New DB driver for capacitor-sqlite.
9 changes: 9 additions & 0 deletions clients/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"active",
"android",
"better-sqlite3",
"capacitor",
"cordova",
"crdt",
"crdts",
Expand Down Expand Up @@ -50,6 +51,7 @@
"./package.json": "./package.json",
"./browser": "./dist/drivers/wa-sqlite/index.js",
"./cordova": "./dist/drivers/cordova-sqlite-storage/index.js",
"./capacitor": "./dist/drivers/capacitor-sqlite/index.js",
"./expo": "./dist/drivers/expo-sqlite/index.js",
"./generic": "./dist/drivers/generic/index.js",
"./node": "./dist/drivers/better-sqlite3/index.js",
Expand All @@ -69,6 +71,9 @@
"cordova": [
"./dist/drivers/cordova-sqlite-storage/index.d.ts"
],
"capacitor": [
"./dist/drivers/capacitor-sqlite/index.d.ts"
],
"expo": [
"./dist/drivers/expo-sqlite/index.d.ts"
],
Expand Down Expand Up @@ -215,6 +220,7 @@
"web-worker": "^1.2.0"
},
"peerDependencies": {
"@capacitor-community/sqlite": ">= 5.2.3",
"cordova-sqlite-storage": ">= 5.0.0",
"expo-sqlite": ">= 10.0.0",
"react": ">= 16.8.0",
Expand All @@ -225,6 +231,9 @@
"wa-sqlite": "git+https://github.com/rhashimoto/wa-sqlite#master"
},
"peerDependenciesMeta": {
"capacitor-community/sqlite": {
"optional": true
},
"cordova-sqlite-storage": {
"optional": true
},
Expand Down
151 changes: 151 additions & 0 deletions clients/typescript/src/drivers/capacitor-sqlite/adapter.ts
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 clients/typescript/src/drivers/capacitor-sqlite/database.ts
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
}
41 changes: 41 additions & 0 deletions clients/typescript/src/drivers/capacitor-sqlite/index.ts
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
}
38 changes: 38 additions & 0 deletions clients/typescript/src/drivers/capacitor-sqlite/mock.ts
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)
}
}
61 changes: 61 additions & 0 deletions clients/typescript/src/drivers/capacitor-sqlite/test.ts
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]
}
3 changes: 3 additions & 0 deletions clients/typescript/src/drivers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import { Database as WASQLiteDatabase } from './wa-sqlite/database'

import { Database as ReactNativeSQLiteStorageDatabase } from './react-native-sqlite-storage/database'

import { Database as CapacitorSQLiteDatabase } from './capacitor-sqlite/database'

export type AnyDatabase =
| BetterSQLite3Database
| CordovaSQLiteStorageDatabase
| ExpoSQLiteDatabase
| ReactNativeSQLiteStorageDatabase
| WASQLiteDatabase
| CapacitorSQLiteDatabase
Loading

0 comments on commit 3d98c1f

Please sign in to comment.