diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f94c135..57c0e4ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog -## 1.3.1 (unreleased) +## 1.4.0 (unreleased) * Update SQLite to 3.50.3. +* Remove internal SQLDelight and SQLiter dependencies. * Android: Ensure JNI libraries are 16KB-aligned. +* Add `rawConnection` getter to `ConnectionContext`, which is a `SQLiteConnection` instance from + `androidx.sqlite` that can be used to step through statements in a custom way. ## 1.3.0 diff --git a/build.gradle.kts b/build.gradle.kts index 92d75028..31801946 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,13 +13,12 @@ plugins { alias(libs.plugins.skie) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.sqldelight) apply false - alias(libs.plugins.grammarKitComposer) apply false alias(libs.plugins.mavenPublishPlugin) apply false alias(libs.plugins.downloadPlugin) apply false alias(libs.plugins.kotlinter) apply false alias(libs.plugins.keeper) apply false alias(libs.plugins.kotlin.atomicfu) apply false + alias(libs.plugins.ksp) apply false id("org.jetbrains.dokka") version libs.versions.dokkaBase id("dokka-convention") } diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index aabc9354..506a38e8 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -19,7 +19,6 @@ kotlin { sourceSets { commonMain.dependencies { api(project(":core")) - implementation(project(":persistence")) implementation(compose.runtime) } androidMain.dependencies { diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 92049353..19280286 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -181,6 +181,7 @@ kotlin { all { languageSettings { optIn("kotlinx.cinterop.ExperimentalForeignApi") + optIn("kotlin.experimental.ExperimentalObjCRefinement") } } @@ -201,6 +202,9 @@ kotlin { } dependencies { + api(libs.kermit) + api(libs.androidx.sqlite) + implementation(libs.uuid) implementation(libs.kotlin.stdlib) implementation(libs.ktor.client.core) @@ -213,8 +217,7 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.stately.concurrency) implementation(libs.configuration.annotations) - api(projects.persistence) - api(libs.kermit) + implementation(projects.drivers.common) } } @@ -233,6 +236,7 @@ kotlin { appleMain.dependencies { implementation(libs.ktor.client.darwin) + implementation(projects.staticSqliteDriver) } commonTest.dependencies { diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 8eba77b2..3b593785 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -1,79 +1,35 @@ package com.powersync import android.content.Context -import com.powersync.db.JdbcSqliteDriver -import com.powersync.db.buildDefaultWalProperties -import com.powersync.db.internal.InternalSchema -import com.powersync.db.migrateDriver -import kotlinx.coroutines.CoroutineScope -import org.sqlite.SQLiteCommitListener -import java.util.concurrent.atomic.AtomicBoolean +import androidx.sqlite.SQLiteConnection +import com.powersync.db.loadExtensions +import com.powersync.internal.driver.AndroidDriver +import com.powersync.internal.driver.ConnectionListener +import com.powersync.internal.driver.JdbcConnection @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public actual class DatabaseDriverFactory( private val context: Context, ) { - internal actual fun createDriver( - scope: CoroutineScope, + internal actual fun openDatabase( dbFilename: String, dbDirectory: String?, readOnly: Boolean, - ): PsSqlDriver { - val schema = InternalSchema - + listener: ConnectionListener?, + ): SQLiteConnection { val dbPath = if (dbDirectory != null) { "$dbDirectory/$dbFilename" } else { - context.getDatabasePath(dbFilename) + "${context.getDatabasePath(dbFilename)}" } - val properties = buildDefaultWalProperties(readOnly = readOnly) - val isFirst = IS_FIRST_CONNECTION.getAndSet(false) - if (isFirst) { - // Make sure the temp_store_directory points towards a temporary directory we actually - // have access to. Due to sandboxing, the default /tmp/ is inaccessible. - // The temp_store_directory pragma is deprecated and not thread-safe, so we only set it - // on the first connection (it sets a global field and will affect every connection - // opened). - val escapedPath = context.cacheDir.absolutePath.replace("\"", "\"\"") - properties.setProperty("temp_store_directory", "\"$escapedPath\"") - } - - val driver = - JdbcSqliteDriver( - url = "jdbc:sqlite:$dbPath", - properties = properties, - ) - - migrateDriver(driver, schema) - - driver.loadExtensions( + val driver = AndroidDriver(context) + val connection = driver.openDatabase(dbPath, readOnly, listener) as JdbcConnection + connection.loadExtensions( "libpowersync.so" to "sqlite3_powersync_init", ) - val mappedDriver = PsSqlDriver(driver = driver) - - driver.connection.database.addUpdateListener { _, _, table, _ -> - mappedDriver.updateTable(table) - } - - driver.connection.database.addCommitListener( - object : SQLiteCommitListener { - override fun onCommit() { - // We track transactions manually - } - - override fun onRollback() { - mappedDriver.clearTableUpdates() - } - }, - ) - - return mappedDriver - } - - private companion object { - val IS_FIRST_CONNECTION = AtomicBoolean(true) + return connection } } diff --git a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt index 943c3a12..d5ab6c71 100644 --- a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt +++ b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt @@ -1,181 +1,46 @@ package com.powersync -import app.cash.sqldelight.db.QueryResult -import co.touchlab.sqliter.DatabaseConfiguration -import co.touchlab.sqliter.DatabaseConfiguration.Logging -import co.touchlab.sqliter.DatabaseConnection -import co.touchlab.sqliter.NO_VERSION_CHECK -import co.touchlab.sqliter.interop.Logger -import co.touchlab.sqliter.interop.SqliteErrorType -import co.touchlab.sqliter.sqlite3.sqlite3_commit_hook -import co.touchlab.sqliter.sqlite3.sqlite3_enable_load_extension -import co.touchlab.sqliter.sqlite3.sqlite3_load_extension -import co.touchlab.sqliter.sqlite3.sqlite3_rollback_hook -import co.touchlab.sqliter.sqlite3.sqlite3_update_hook +import androidx.sqlite.SQLiteConnection import com.powersync.DatabaseDriverFactory.Companion.powerSyncExtensionPath -import com.powersync.db.internal.InternalSchema -import com.powersync.persistence.driver.NativeSqliteDriver -import com.powersync.persistence.driver.wrapConnection +import com.powersync.internal.driver.ConnectionListener +import com.powersync.internal.driver.NativeConnection +import com.powersync.internal.driver.NativeDriver import kotlinx.cinterop.ByteVar import kotlinx.cinterop.CPointerVar import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.MemScope -import kotlinx.cinterop.StableRef +import kotlinx.cinterop.UnsafeNumber import kotlinx.cinterop.alloc -import kotlinx.cinterop.asStableRef import kotlinx.cinterop.free import kotlinx.cinterop.nativeHeap import kotlinx.cinterop.ptr -import kotlinx.cinterop.staticCFunction import kotlinx.cinterop.toKString import kotlinx.cinterop.value -import kotlinx.coroutines.CoroutineScope +import kotlinx.io.files.Path +import platform.Foundation.NSApplicationSupportDirectory import platform.Foundation.NSBundle +import platform.Foundation.NSFileManager +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSUserDomainMask +import sqlite3.SQLITE_OK +import sqlite3.sqlite3_enable_load_extension +import sqlite3.sqlite3_load_extension @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @OptIn(ExperimentalForeignApi::class) public actual class DatabaseDriverFactory { - internal actual fun createDriver( - scope: CoroutineScope, + internal actual fun openDatabase( dbFilename: String, dbDirectory: String?, readOnly: Boolean, - ): PsSqlDriver { - val schema = InternalSchema - val sqlLogger = - object : Logger { - override val eActive: Boolean - get() = false - override val vActive: Boolean - get() = false - - override fun eWrite( - message: String, - exception: Throwable?, - ) { - } - - override fun trace(message: String) {} - - override fun vWrite(message: String) {} - } - - // Create a deferred driver reference for hook registrations - // This must exist before we create the driver since we require - // a pointer for C hooks - val deferredDriver = DeferredDriver() - - val driver = - PsSqlDriver( - driver = - NativeSqliteDriver( - configuration = - DatabaseConfiguration( - name = dbFilename, - version = - if (!readOnly) { - schema.version.toInt() - } else { - // Don't do migrations on read only connections - NO_VERSION_CHECK - }, - create = { connection -> - wrapConnection(connection) { - schema.create( - it, - ) - } - }, - loggingConfig = Logging(logger = sqlLogger), - lifecycleConfig = - DatabaseConfiguration.Lifecycle( - onCreateConnection = { connection -> - setupSqliteBinding(connection, deferredDriver) - wrapConnection(connection) { driver -> - schema.create(driver) - } - }, - onCloseConnection = { connection -> - deregisterSqliteBinding(connection) - }, - ), - ), - ), - ) - - // The iOS driver implementation generates 1 write and 1 read connection internally - // It uses the read connection for all queries and the write connection for all - // execute statements. Unfortunately the driver does not seem to respond to query - // calls if the read connection count is set to zero. - // We'd like to ensure a driver is set to read-only. Ideally we could do this in the - // onCreateConnection lifecycle hook, but this runs before driver internal migrations. - // Setting the connection to read only there breaks migrations. - // We explicitly execute this pragma to reflect and guard the "write" connection. - // The read connection already has this set. - if (readOnly) { - driver.execute("PRAGMA query_only=true") - } - - // Ensure internal read pool has created a connection at this point. This makes connection - // initialization a bit more deterministic. - driver.executeQuery( - identifier = null, - sql = "SELECT 1", - mapper = { QueryResult.Value(it.getLong(0)) }, - parameters = 0, - ) - - deferredDriver.setDriver(driver) - - return driver - } - - private fun setupSqliteBinding( - connection: DatabaseConnection, - driver: DeferredDriver, - ) { - connection.loadPowerSyncSqliteCoreExtension() - - val ptr = connection.getDbPointer().getPointer(MemScope()) - val driverRef = StableRef.create(driver) - - sqlite3_update_hook( - ptr, - staticCFunction { usrPtr, updateType, dbName, tableName, rowId -> - usrPtr!! - .asStableRef() - .get() - .updateTableHook(tableName!!.toKString()) - }, - driverRef.asCPointer(), - ) - - sqlite3_commit_hook( - ptr, - staticCFunction { usrPtr -> - usrPtr!!.asStableRef().get().onTransactionCommit(true) - 0 - }, - driverRef.asCPointer(), - ) - - sqlite3_rollback_hook( - ptr, - staticCFunction { usrPtr -> - usrPtr!!.asStableRef().get().onTransactionCommit(false) - }, - driverRef.asCPointer(), - ) - } - - private fun deregisterSqliteBinding(connection: DatabaseConnection) { - val basePtr = connection.getDbPointer().getPointer(MemScope()) - - sqlite3_update_hook( - basePtr, - null, - null, - ) + listener: ConnectionListener?, + ): SQLiteConnection { + val directory = dbDirectory ?: defaultDatabaseDirectory() + val path = Path(directory, dbFilename).toString() + val db = NativeDriver().openNativeDatabase(path, readOnly, listener) + + db.loadPowerSyncSqliteCoreExtension() + return db } internal companion object { @@ -192,18 +57,35 @@ public actual class DatabaseDriverFactory { // Construct full path to the shared library inside the bundle bundlePath.let { "$it/powersync-sqlite-core" } } + + @OptIn(UnsafeNumber::class) + private fun defaultDatabaseDirectory(search: String = "databases"): String { + // This needs to be compatible with https://github.com/touchlab/SQLiter/blob/a37bbe7e9c65e6a5a94c5bfcaccdaae55ad2bac9/sqliter-driver/src/appleMain/kotlin/co/touchlab/sqliter/DatabaseFileContext.kt#L36-L51 + val paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true) + val documentsDirectory = paths[0] as String + + val databaseDirectory = "$documentsDirectory/$search" + + val fileManager = NSFileManager.defaultManager() + + if (!fileManager.fileExistsAtPath(databaseDirectory)) { + fileManager.createDirectoryAtPath(databaseDirectory, true, null, null) + }; // Create folder + + return databaseDirectory + } } } -internal fun DatabaseConnection.loadPowerSyncSqliteCoreExtensionDynamically() { - val ptr = getDbPointer().getPointer(MemScope()) +internal fun NativeConnection.loadPowerSyncSqliteCoreExtensionDynamically() { + val ptr = sqlite.getPointer(MemScope()) val extensionPath = powerSyncExtensionPath // Enable extension loading // We don't disable this after the fact, this should allow users to load their own extensions // in future. val enableResult = sqlite3_enable_load_extension(ptr, 1) - if (enableResult != SqliteErrorType.SQLITE_OK.code) { + if (enableResult != SQLITE_OK) { throw PowerSyncException( "Could not dynamically load the PowerSync SQLite core extension", cause = @@ -219,7 +101,7 @@ internal fun DatabaseConnection.loadPowerSyncSqliteCoreExtensionDynamically() { sqlite3_load_extension(ptr, extensionPath, "sqlite3_powersync_init", errMsg.ptr) val resultingError = errMsg.value nativeHeap.free(errMsg) - if (result != SqliteErrorType.SQLITE_OK.code) { + if (result != SQLITE_OK) { val errorMessage = resultingError?.toKString() ?: "Unknown error" throw PowerSyncException( "Could not load the PowerSync SQLite core extension", @@ -231,4 +113,4 @@ internal fun DatabaseConnection.loadPowerSyncSqliteCoreExtensionDynamically() { } } -internal expect fun DatabaseConnection.loadPowerSyncSqliteCoreExtension() +internal expect fun NativeConnection.loadPowerSyncSqliteCoreExtension() diff --git a/core/src/appleMain/kotlin/com/powersync/DeferredDriver.kt b/core/src/appleMain/kotlin/com/powersync/DeferredDriver.kt deleted file mode 100644 index f4c0b5fc..00000000 --- a/core/src/appleMain/kotlin/com/powersync/DeferredDriver.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.powersync - -/** - * In some cases we require an instance of a driver for hook registrations - * before the driver has been instantiated. - */ -internal class DeferredDriver { - private var driver: PsSqlDriver? = null - - fun setDriver(driver: PsSqlDriver) { - this.driver = driver - } - - fun updateTableHook(tableName: String) { - driver?.updateTable(tableName) - } - - fun onTransactionCommit(success: Boolean) { - driver?.also { driver -> - // Only clear updates on rollback - // We manually fire updates when a transaction ended - if (!success) { - driver.clearTableUpdates() - } - } - } -} diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 3e5f5f19..ce524e16 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -3,6 +3,7 @@ package com.powersync import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi import com.powersync.db.ActiveDatabaseGroup +import com.powersync.db.getString import com.powersync.db.schema.Schema import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest @@ -17,11 +18,13 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalKermitApi::class) class DatabaseTest { @@ -459,4 +462,54 @@ class DatabaseTest { database.getCrudBatch() shouldBe null } + + @Test + @OptIn(ExperimentalPowerSyncAPI::class) + fun testLeaseReadOnly() = + databaseTest { + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf("a", "a@example.org"), + ) + + val raw = database.leaseConnection(readOnly = true) + raw.prepare("SELECT * FROM users").use { stmt -> + stmt.step() shouldBe true + stmt.getText(1) shouldBe "a" + stmt.getText(2) shouldBe "a@example.org" + } + raw.close() + } + + @Test + @OptIn(ExperimentalPowerSyncAPI::class) + fun testLeaseWrite() = + databaseTest { + val raw = database.leaseConnection(readOnly = false) + raw.prepare("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)").use { stmt -> + stmt.bindText(1, "name") + stmt.bindText(2, "email") + stmt.step() shouldBe false + + stmt.reset() + stmt.step() shouldBe false + } + + database.getAll("SELECT * FROM users") { it.getString("name") } shouldHaveSize 2 + + // Verify that the statement indeed holds a lock on the database. + val hadOtherWrite = CompletableDeferred() + scope.launch { + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf("another", "a@example.org"), + ) + hadOtherWrite.complete(Unit) + } + + delay(100.milliseconds) + hadOtherWrite.isCompleted shouldBe false + raw.close() + hadOtherWrite.await() + } } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index 84743ac0..8e314629 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -597,7 +597,12 @@ abstract class BaseSyncIntegrationTest( val turbine = database.currentStatus.asFlow().testIn(scope) turbine.waitFor { it.connected } - val query = database.watch("SELECT name FROM users") { it.getString(0)!! }.testIn(scope) + val query = + database + .watch("SELECT name FROM users") { + println("interpreting results: ${it.getString(0)}") + it.getString(0)!! + }.testIn(scope) query.awaitItem() shouldBe listOf("local write") syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 1234)) diff --git a/core/src/commonJava/kotlin/com/powersync/db/JdbcPreparedStatement.kt b/core/src/commonJava/kotlin/com/powersync/db/JdbcPreparedStatement.kt deleted file mode 100644 index c3c98dfd..00000000 --- a/core/src/commonJava/kotlin/com/powersync/db/JdbcPreparedStatement.kt +++ /dev/null @@ -1,226 +0,0 @@ -package com.powersync.db - -import app.cash.sqldelight.db.QueryResult -import app.cash.sqldelight.db.SqlCursor -import app.cash.sqldelight.db.SqlPreparedStatement -import com.powersync.persistence.driver.ColNamesSqlCursor -import java.math.BigDecimal -import java.sql.PreparedStatement -import java.sql.ResultSet -import java.sql.Types - -/** - * Binds the parameter to [preparedStatement] by calling [bindString], [bindLong] or similar. - * After binding, [execute] executes the query without a result, while [executeQuery] returns [JdbcCursor]. - */ -public class JdbcPreparedStatement( - private val preparedStatement: PreparedStatement, -) : SqlPreparedStatement { - override fun bindBytes( - index: Int, - bytes: ByteArray?, - ) { - preparedStatement.setBytes(index + 1, bytes) - } - - override fun bindBoolean( - index: Int, - boolean: Boolean?, - ) { - if (boolean == null) { - preparedStatement.setNull(index + 1, Types.BOOLEAN) - } else { - preparedStatement.setBoolean(index + 1, boolean) - } - } - - public fun bindByte( - index: Int, - byte: Byte?, - ) { - if (byte == null) { - preparedStatement.setNull(index + 1, Types.TINYINT) - } else { - preparedStatement.setByte(index + 1, byte) - } - } - - public fun bindShort( - index: Int, - short: Short?, - ) { - if (short == null) { - preparedStatement.setNull(index + 1, Types.SMALLINT) - } else { - preparedStatement.setShort(index + 1, short) - } - } - - public fun bindInt( - index: Int, - int: Int?, - ) { - if (int == null) { - preparedStatement.setNull(index + 1, Types.INTEGER) - } else { - preparedStatement.setInt(index + 1, int) - } - } - - override fun bindLong( - index: Int, - long: Long?, - ) { - if (long == null) { - preparedStatement.setNull(index + 1, Types.BIGINT) - } else { - preparedStatement.setLong(index + 1, long) - } - } - - public fun bindFloat( - index: Int, - float: Float?, - ) { - if (float == null) { - preparedStatement.setNull(index + 1, Types.REAL) - } else { - preparedStatement.setFloat(index + 1, float) - } - } - - override fun bindDouble( - index: Int, - double: Double?, - ) { - if (double == null) { - preparedStatement.setNull(index + 1, Types.DOUBLE) - } else { - preparedStatement.setDouble(index + 1, double) - } - } - - public fun bindBigDecimal( - index: Int, - decimal: BigDecimal?, - ) { - preparedStatement.setBigDecimal(index + 1, decimal) - } - - public fun bindObject( - index: Int, - obj: Any?, - ) { - if (obj == null) { - preparedStatement.setNull(index + 1, Types.OTHER) - } else { - preparedStatement.setObject(index + 1, obj) - } - } - - public fun bindObject( - index: Int, - obj: Any?, - type: Int, - ) { - if (obj == null) { - preparedStatement.setNull(index + 1, type) - } else { - preparedStatement.setObject(index + 1, obj, type) - } - } - - override fun bindString( - index: Int, - string: String?, - ) { - preparedStatement.setString(index + 1, string) - } - - public fun bindDate( - index: Int, - date: java.sql.Date?, - ) { - preparedStatement.setDate(index, date) - } - - public fun bindTime( - index: Int, - date: java.sql.Time?, - ) { - preparedStatement.setTime(index, date) - } - - public fun bindTimestamp( - index: Int, - timestamp: java.sql.Timestamp?, - ) { - preparedStatement.setTimestamp(index, timestamp) - } - - public fun executeQuery(mapper: (SqlCursor) -> R): R { - try { - return preparedStatement - .executeQuery() - .use { resultSet -> mapper(JdbcCursor(resultSet)) } - } finally { - preparedStatement.close() - } - } - - public fun execute(): Long = - if (preparedStatement.execute()) { - // returned true so this is a result set return type. - 0L - } else { - preparedStatement.updateCount.toLong() - } -} - -/** - * Iterate each row in [resultSet] and map the columns to Kotlin classes by calling [getString], [getLong] etc. - * Use [next] to retrieve the next row and [close] to close the connection. - */ -internal class JdbcCursor( - val resultSet: ResultSet, -) : ColNamesSqlCursor { - override fun getString(index: Int): String? = resultSet.getString(index + 1) - - override fun getBytes(index: Int): ByteArray? = resultSet.getBytes(index + 1) - - override fun getBoolean(index: Int): Boolean? = getAtIndex(index, resultSet::getBoolean) - - override fun columnName(index: Int): String? = resultSet.metaData.getColumnName(index + 1) - - override val columnCount: Int = resultSet.metaData.columnCount - - fun getByte(index: Int): Byte? = getAtIndex(index, resultSet::getByte) - - fun getShort(index: Int): Short? = getAtIndex(index, resultSet::getShort) - - fun getInt(index: Int): Int? = getAtIndex(index, resultSet::getInt) - - override fun getLong(index: Int): Long? = getAtIndex(index, resultSet::getLong) - - fun getFloat(index: Int): Float? = getAtIndex(index, resultSet::getFloat) - - override fun getDouble(index: Int): Double? = getAtIndex(index, resultSet::getDouble) - - fun getBigDecimal(index: Int): BigDecimal? = resultSet.getBigDecimal(index + 1) - - fun getDate(index: Int): java.sql.Date? = resultSet.getDate(index) - - fun getTime(index: Int): java.sql.Time? = resultSet.getTime(index) - - fun getTimestamp(index: Int): java.sql.Timestamp? = resultSet.getTimestamp(index) - - @Suppress("UNCHECKED_CAST") - fun getArray(index: Int) = getAtIndex(index, resultSet::getArray)?.array as Array? - - private fun getAtIndex( - index: Int, - converter: (Int) -> T, - ): T? = converter(index + 1).takeUnless { resultSet.wasNull() } - - override fun next(): QueryResult.Value = QueryResult.Value(resultSet.next()) -} diff --git a/core/src/commonJava/kotlin/com/powersync/db/JdbcSqliteDriver.kt b/core/src/commonJava/kotlin/com/powersync/db/JdbcSqliteDriver.kt deleted file mode 100644 index fc4d76b0..00000000 --- a/core/src/commonJava/kotlin/com/powersync/db/JdbcSqliteDriver.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.powersync.db - -import app.cash.sqldelight.Query -import app.cash.sqldelight.Transacter -import app.cash.sqldelight.db.AfterVersion -import app.cash.sqldelight.db.QueryResult -import app.cash.sqldelight.db.SqlCursor -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.db.SqlPreparedStatement -import app.cash.sqldelight.db.SqlSchema -import org.sqlite.SQLiteConnection -import java.sql.DriverManager -import java.sql.PreparedStatement -import java.util.Properties - -@Suppress("SqlNoDataSourceInspection", "SqlSourceToSinkFlow") -internal class JdbcSqliteDriver( - url: String, - properties: Properties = Properties(), -) : SqlDriver { - val connection: SQLiteConnection = - DriverManager.getConnection(url, properties) as SQLiteConnection - - private var transaction: Transaction? = null - - override fun addListener( - vararg queryKeys: String, - listener: Query.Listener, - ) { - // No Op, we don't currently use this - } - - override fun removeListener( - vararg queryKeys: String, - listener: Query.Listener, - ) { - // No Op, we don't currently use this - } - - override fun notifyListeners(vararg queryKeys: String) { - // No Op, we don't currently use this - } - - fun setVersion(version: Long) { - execute(null, "PRAGMA user_version = $version", 0, null).value - } - - fun getVersion(): Long { - val mapper = { cursor: SqlCursor -> - QueryResult.Value(if (cursor.next().value) cursor.getLong(0) else null) - } - return executeQuery(null, "PRAGMA user_version", mapper, 0, null).value ?: 0L - } - - override fun newTransaction(): QueryResult { - val newTransaction = Transaction(transaction) - transaction = newTransaction - return QueryResult.Value(newTransaction) - } - - override fun close() { - connection.close() - } - - override fun currentTransaction(): Transacter.Transaction? = transaction - - @Synchronized - override fun execute( - identifier: Int?, - sql: String, - parameters: Int, - binders: (SqlPreparedStatement.() -> Unit)?, - ): QueryResult = - QueryResult.Value( - connection.prepareStatement(sql).use { - val stmt = JdbcPreparedStatement(it) - binders?.invoke(stmt) - stmt.execute() - }, - ) - - @Synchronized - override fun executeQuery( - identifier: Int?, - sql: String, - mapper: (SqlCursor) -> QueryResult, - parameters: Int, - binders: (SqlPreparedStatement.() -> Unit)?, - ): QueryResult = - connection.prepareStatement(sql).use { - val stmt = JdbcPreparedStatement(it) - binders?.invoke(stmt) - stmt.executeQuery(mapper) - } - - internal fun loadExtensions(vararg extensions: Pair) { - connection.database.enable_load_extension(true) - extensions.forEach { (path, entryPoint) -> - val executed = - connection.prepareStatement("SELECT load_extension(?, ?);").use { statement -> - statement.setString(1, path) - statement.setString(2, entryPoint) - statement.execute() - } - check(executed) { "load_extension(\"${path}\", \"${entryPoint}\") failed" } - } - connection.database.enable_load_extension(false) - } - - private inner class Transaction( - override val enclosingTransaction: Transaction?, - ) : Transacter.Transaction() { - init { - assert(enclosingTransaction == null) { "Nested transactions are not supported" } - connection.prepareStatement("BEGIN TRANSACTION").use(PreparedStatement::execute) - } - - override fun endTransaction(successful: Boolean): QueryResult { - if (enclosingTransaction == null) { - if (successful) { - connection.prepareStatement("END TRANSACTION").use(PreparedStatement::execute) - } else { - connection - .prepareStatement("ROLLBACK TRANSACTION") - .use(PreparedStatement::execute) - } - } - transaction = enclosingTransaction - return QueryResult.Unit - } - } -} - -internal fun migrateDriver( - driver: JdbcSqliteDriver, - schema: SqlSchema>, - migrateEmptySchema: Boolean = false, - vararg callbacks: AfterVersion, -) { - val version = driver.getVersion() - - if (version == 0L && !migrateEmptySchema) { - schema.create(driver).value - driver.setVersion(schema.version) - } else if (version < schema.version) { - schema.migrate(driver, version, schema.version, *callbacks).value - driver.setVersion(schema.version) - } -} diff --git a/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt b/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt new file mode 100644 index 00000000..42febe15 --- /dev/null +++ b/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt @@ -0,0 +1,17 @@ +package com.powersync.db + +import com.powersync.internal.driver.JdbcConnection + +internal fun JdbcConnection.loadExtensions(vararg extensions: Pair) { + connection.database.enable_load_extension(true) + extensions.forEach { (path, entryPoint) -> + val executed = + connection.prepareStatement("SELECT load_extension(?, ?);").use { statement -> + statement.setString(1, path) + statement.setString(2, entryPoint) + statement.execute() + } + check(executed) { "load_extension(\"${path}\", \"${entryPoint}\") failed" } + } + connection.database.enable_load_extension(false) +} diff --git a/core/src/commonJava/kotlin/com/powersync/db/WalProperties.kt b/core/src/commonJava/kotlin/com/powersync/db/WalProperties.kt deleted file mode 100644 index 5fa9a082..00000000 --- a/core/src/commonJava/kotlin/com/powersync/db/WalProperties.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.powersync.db - -import java.util.Properties - -internal fun buildDefaultWalProperties(readOnly: Boolean = false): Properties { - // WAL Mode properties - val properties = Properties() - properties.setProperty("journal_mode", "WAL") - properties.setProperty("journal_size_limit", "${6 * 1024 * 1024}") - properties.setProperty("busy_timeout", "30000") - properties.setProperty("cache_size", "${50 * 1024}") - - if (readOnly) { - properties.setProperty("open_mode", "1") - } - - return properties -} diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 2d781f9e..cce71f19 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -1,13 +1,14 @@ package com.powersync -import kotlinx.coroutines.CoroutineScope +import androidx.sqlite.SQLiteConnection +import com.powersync.internal.driver.ConnectionListener @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public expect class DatabaseDriverFactory { - internal fun createDriver( - scope: CoroutineScope, + internal fun openDatabase( dbFilename: String, dbDirectory: String?, readOnly: Boolean = false, - ): PsSqlDriver + listener: ConnectionListener?, + ): SQLiteConnection } diff --git a/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt b/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt deleted file mode 100644 index 2c367e2b..00000000 --- a/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.powersync - -import app.cash.sqldelight.ExecutableQuery -import app.cash.sqldelight.db.QueryResult -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.db.SqlPreparedStatement -import com.powersync.db.SqlCursor -import com.powersync.db.internal.ConnectionContext -import com.powersync.db.internal.getBindersFromParams -import com.powersync.db.internal.wrapperMapper -import com.powersync.db.runWrapped -import com.powersync.utils.AtomicMutableSet -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -internal class PsSqlDriver( - private val driver: SqlDriver, -) : SqlDriver by driver, - ConnectionContext { - // MutableSharedFlow to emit batched table updates - private val tableUpdatesFlow = MutableSharedFlow>(replay = 0) - - // In-memory buffer to store table names before flushing - private val pendingUpdates = AtomicMutableSet() - - fun updateTable(tableName: String) { - pendingUpdates.add(tableName) - } - - fun clearTableUpdates() { - pendingUpdates.clear() - } - - // Flows on any table change - // This specifically returns a SharedFlow for downstream timing considerations - fun updatesOnTables(): SharedFlow> = - tableUpdatesFlow - .asSharedFlow() - - suspend fun fireTableUpdates() { - val updates = pendingUpdates.toSetAndClear() - tableUpdatesFlow.emit(updates) - } - - override fun execute( - sql: String, - parameters: List?, - ): Long { - val numParams = parameters?.size ?: 0 - - return runWrapped { - driver - .execute( - identifier = null, - sql = sql, - parameters = numParams, - binders = getBindersFromParams(parameters), - ).value - } - } - - override fun get( - sql: String, - parameters: List?, - mapper: (SqlCursor) -> RowType, - ): RowType { - val result = - this - .createQuery( - query = sql, - parameters = parameters?.size ?: 0, - binders = getBindersFromParams(parameters), - mapper = mapper, - ).executeAsOneOrNull() - return requireNotNull(result) { "Query returned no result" } - } - - override fun getAll( - sql: String, - parameters: List?, - mapper: (SqlCursor) -> RowType, - ): List = - this - .createQuery( - query = sql, - parameters = parameters?.size ?: 0, - binders = getBindersFromParams(parameters), - mapper = mapper, - ).executeAsList() - - override fun getOptional( - sql: String, - parameters: List?, - mapper: (SqlCursor) -> RowType, - ): RowType? = - this - .createQuery( - query = sql, - parameters = parameters?.size ?: 0, - binders = getBindersFromParams(parameters), - mapper = mapper, - ).executeAsOneOrNull() - - private fun createQuery( - query: String, - mapper: (SqlCursor) -> T, - parameters: Int = 0, - binders: (SqlPreparedStatement.() -> Unit)? = null, - ): ExecutableQuery = - object : ExecutableQuery(wrapperMapper(mapper)) { - override fun execute(mapper: (app.cash.sqldelight.db.SqlCursor) -> QueryResult): QueryResult = - runWrapped { - driver.executeQuery(null, query, mapper, parameters, binders) - } - } -} diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 51880ee1..db705507 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -1,7 +1,9 @@ package com.powersync.db +import androidx.sqlite.SQLiteConnection import co.touchlab.kermit.Logger import com.powersync.DatabaseDriverFactory +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase import com.powersync.PowerSyncException import com.powersync.bucket.BucketPriority @@ -90,6 +92,7 @@ internal class PowerSyncDatabaseImpl( dbFilename = dbFilename, dbDirectory = dbDirectory, writeLockMutex = resource.group.writeLockMutex, + logger = logger, ) internal val bucketStorage: BucketStorage = BucketStorageImpl(internalDb, logger) @@ -315,6 +318,12 @@ internal class PowerSyncDatabaseImpl( return powerSyncVersion } + @ExperimentalPowerSyncAPI + override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnection { + waitReady() + return internalDb.leaseConnection(readOnly) + } + override suspend fun get( sql: String, parameters: List?, diff --git a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt index 0f41cb54..72cefb40 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt @@ -1,10 +1,13 @@ package com.powersync.db +import androidx.sqlite.SQLiteConnection +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException import com.powersync.db.internal.ConnectionContext import com.powersync.db.internal.PowerSyncTransaction import kotlinx.coroutines.flow.Flow import kotlin.coroutines.cancellation.CancellationException +import kotlin.native.HiddenFromObjC import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -183,4 +186,21 @@ public interface Queries { */ @Throws(PowerSyncException::class, CancellationException::class) public suspend fun readTransaction(callback: ThrowableTransactionCallback): R + + /** + * Obtains a connection from the read pool or an exclusive reference on the write connection. + * + * This is useful when you need full control over the raw statements to use. + * + * The connection needs to be released by calling [SQLiteConnection.close] as soon as you're + * done with it, because the connection will occupy a read resource or the write lock while + * active. + * + * Misusing this API, for instance by not cleaning up transactions started on the underlying + * connection with a `BEGIN` statement or forgetting to close it, can disrupt the rest of the + * PowerSync SDK. For this reason, this method should only be used if absolutely necessary. + */ + @ExperimentalPowerSyncAPI() + @HiddenFromObjC() + public suspend fun leaseConnection(readOnly: Boolean = false): SQLiteConnection } diff --git a/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt b/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt index bca14a55..64fed97a 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt @@ -1,7 +1,9 @@ package com.powersync.db +import androidx.sqlite.SQLiteStatement import co.touchlab.skie.configuration.annotations.FunctionInterop import com.powersync.PowerSyncException +import kotlin.collections.set public interface SqlCursor { public fun getBoolean(index: Int): Boolean? @@ -29,6 +31,56 @@ private inline fun SqlCursor.getColumnValue( return getValue(index) ?: throw IllegalArgumentException("Null value found for column '$name'") } +internal class StatementBasedCursor( + private val stmt: SQLiteStatement, +) : SqlCursor { + override fun getBoolean(index: Int): Boolean? = getNullable(index) { index -> stmt.getLong(index) != 0L } + + override fun getBytes(index: Int): ByteArray? = getNullable(index, SQLiteStatement::getBlob) + + override fun getDouble(index: Int): Double? = getNullable(index, SQLiteStatement::getDouble) + + override fun getLong(index: Int): Long? = getNullable(index, SQLiteStatement::getLong) + + override fun getString(index: Int): String? = getNullable(index, SQLiteStatement::getText) + + private inline fun getNullable( + index: Int, + read: SQLiteStatement.(Int) -> T, + ): T? = + if (stmt.isNull(index)) { + null + } else { + stmt.read(index) + } + + override fun columnName(index: Int): String? = stmt.getColumnName(index) + + override val columnCount: Int + get() = stmt.getColumnCount() + + override val columnNames: Map by lazy { + buildMap { + stmt.getColumnNames().forEachIndexed { index, key -> + val finalKey = + if (containsKey(key)) { + var index = 1 + val basicKey = "$key&JOIN" + var finalKey = basicKey + index + while (containsKey(finalKey)) { + finalKey = basicKey + ++index + } + finalKey + } else { + key + } + + put(finalKey, index) + } + } + } +} + private inline fun SqlCursor.getColumnValueOptional( name: String, getValue: (Int) -> T?, diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt index 1bd5b6d4..5345bb47 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt @@ -1,7 +1,10 @@ package com.powersync.db.internal +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement import com.powersync.PowerSyncException import com.powersync.db.SqlCursor +import com.powersync.db.StatementBasedCursor public interface ConnectionContext { @Throws(PowerSyncException::class) @@ -31,3 +34,90 @@ public interface ConnectionContext { mapper: (SqlCursor) -> RowType, ): RowType } + +internal class ConnectionContextImplementation( + private val rawConnection: SQLiteConnection, +) : ConnectionContext { + override fun execute( + sql: String, + parameters: List?, + ): Long { + withStatement(sql, parameters) { + while (it.step()) { + // Iterate through the statement + } + + // TODO: What is this even supposed to return + return 0L + } + } + + override fun getOptional( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType, + ): RowType? = + withStatement(sql, parameters) { stmt -> + if (stmt.step()) { + mapper(StatementBasedCursor(stmt)) + } else { + null + } + } + + override fun getAll( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType, + ): List = + withStatement(sql, parameters) { stmt -> + buildList { + val cursor = StatementBasedCursor(stmt) + while (stmt.step()) { + add(mapper(cursor)) + } + } + } + + override fun get( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType, + ): RowType = getOptional(sql, parameters, mapper) ?: throw PowerSyncException("get() called with query that returned no rows", null) + + private inline fun withStatement( + sql: String, + parameters: List?, + block: (SQLiteStatement) -> T, + ): T = prepareStmt(sql, parameters).use(block) + + private fun prepareStmt( + sql: String, + parameters: List?, + ): SQLiteStatement = + rawConnection.prepare(sql).apply { + try { + parameters?.forEachIndexed { i, parameter -> + // SQLite parameters are 1-indexed + val index = i + 1 + + when (parameter) { + is Boolean -> bindBoolean(index, parameter) + is String -> bindText(index, parameter) + is Long -> bindLong(index, parameter) + is Int -> bindLong(index, parameter.toLong()) + is Double -> bindDouble(index, parameter) + is ByteArray -> bindBlob(index, parameter) + else -> { + if (parameter != null) { + throw IllegalArgumentException("Unsupported parameter type: ${parameter::class}, at index $index") + } + } + } + } + } catch (e: Exception) { + close() + throw e + } + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt index c991d6a3..c72f9a5b 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt @@ -1,7 +1,7 @@ package com.powersync.db.internal +import androidx.sqlite.SQLiteConnection import com.powersync.PowerSyncException -import com.powersync.PsSqlDriver import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -12,15 +12,15 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch internal class ConnectionPool( - factory: () -> PsSqlDriver, + factory: () -> SQLiteConnection, size: Int = 5, private val scope: CoroutineScope, ) { - private val available = Channel>>() + private val available = Channel>>() private val connections: List = List(size) { scope.launch { - val driver = TransactorDriver(factory()) + val driver = factory() try { while (true) { val done = CompletableDeferred() @@ -33,12 +33,12 @@ internal class ConnectionPool( done.await() } } finally { - driver.driver.close() + driver.close() } } } - suspend fun withConnection(action: suspend (connection: TransactorDriver) -> R): R { + suspend fun obtainConnection(): RawConnectionLease { val (connection, done) = try { available.receive() @@ -49,15 +49,11 @@ internal class ConnectionPool( ) } - try { - return action(connection) - } finally { - done.complete(Unit) - } + return RawConnectionLease(connection) { done.complete(Unit) } } - suspend fun withAllConnections(action: suspend (connections: List) -> R): R { - val obtainedConnections = mutableListOf>>() + suspend fun withAllConnections(action: suspend (connections: List) -> R): R { + val obtainedConnections = mutableListOf>>() try { /** diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt index af47b95f..164cede9 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -1,7 +1,10 @@ package com.powersync.db.internal -import app.cash.sqldelight.db.SqlPreparedStatement +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import co.touchlab.kermit.Logger import com.powersync.DatabaseDriverFactory +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException import com.powersync.db.SqlCursor import com.powersync.db.ThrowableLockCallback @@ -20,39 +23,65 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.flow.transform import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.milliseconds internal class InternalDatabaseImpl( private val factory: DatabaseDriverFactory, private val scope: CoroutineScope, + logger: Logger, private val dbFilename: String, private val dbDirectory: String?, private val writeLockMutex: Mutex, ) : InternalDatabase { - private val writeConnection = - TransactorDriver( - factory.createDriver( - scope = scope, - dbFilename = dbFilename, - dbDirectory = dbDirectory, - ), - ) + private val updates = UpdateFlow(logger) + + private val writeConnection = newConnection(false) private val readPool = ConnectionPool(factory = { - factory.createDriver( - scope = scope, - dbFilename = dbFilename, - dbDirectory = dbDirectory, - readOnly = true, - ) + newConnection(true) }, scope = scope) // Could be scope.coroutineContext, but the default is GlobalScope, which seems like a bad idea. To discuss. private val dbContext = Dispatchers.IO + private fun newConnection(readOnly: Boolean): SQLiteConnection { + val connection = + factory.openDatabase( + dbFilename = dbFilename, + dbDirectory = dbDirectory, + readOnly = false, + // We don't need a listener on read-only connections since we don't expect any update + // hooks here. + listener = if (readOnly) null else updates, + ) + + connection.execSQL("pragma journal_mode = WAL") + connection.execSQL("pragma journal_size_limit = ${6 * 1024 * 1024}") + connection.execSQL("pragma busy_timeout = 30000") + connection.execSQL("pragma cache_size = ${50 * 1024}") + + if (readOnly) { + connection.execSQL("pragma query_only = TRUE") + } + + // Older versions of the SDK used to set up an empty schema and raise the user version to 1. + // Keep doing that for consistency. + if (!readOnly) { + val version = + connection.prepare("pragma user_version").use { + require(it.step()) + if (it.isNull(0)) 0L else it.getLong(0) + } + if (version < 1L) { + connection.execSQL("pragma user_version = 1") + } + } + + return connection + } + override suspend fun execute( sql: String, parameters: List?, @@ -75,7 +104,10 @@ internal class InternalDatabaseImpl( } // Update the schema on all read connections - readConnections.forEach { it.driver.getAll("pragma table_info('sqlite_master')") {} } + for (readConnection in readConnections) { + ConnectionContextImplementation(readConnection) + .getAll("pragma table_info('sqlite_master')") {} + } } } } @@ -174,78 +206,89 @@ internal class InternalDatabaseImpl( } } + @ExperimentalPowerSyncAPI + override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnection = + if (readOnly) { + readPool.obtainConnection() + } else { + writeLockMutex.lock() + RawConnectionLease(writeConnection, writeLockMutex::unlock) + } + /** * Creates a read lock while providing an internal transactor for transactions */ - private suspend fun internalReadLock(callback: (TransactorDriver) -> R): R = + @OptIn(ExperimentalPowerSyncAPI::class) + private suspend fun internalReadLock(callback: (SQLiteConnection) -> R): R = withContext(dbContext) { runWrapped { - readPool.withConnection { + val connection = leaseConnection(readOnly = true) + try { catchSwiftExceptions { - callback(it) + callback(connection) } + } finally { + // Closing the lease will release the connection back into the pool. + connection.close() } } } override suspend fun readLock(callback: ThrowableLockCallback): R = internalReadLock { - callback.execute(it.driver) + callback.execute(ConnectionContextImplementation(it)) } override suspend fun readTransaction(callback: ThrowableTransactionCallback): R = internalReadLock { - it.transactor.transactionWithResult(noEnclosing = true) { + it.runTransaction { tx -> catchSwiftExceptions { - callback.execute( - PowerSyncTransactionImpl( - it.driver, - ), - ) + callback.execute(tx) } } } - private suspend fun internalWriteLock(callback: (TransactorDriver) -> R): R = + @OptIn(ExperimentalPowerSyncAPI::class) + private suspend fun internalWriteLock(callback: (SQLiteConnection) -> R): R = withContext(dbContext) { - writeLockMutex.withLock { + val lease = leaseConnection(readOnly = false) + try { runWrapped { catchSwiftExceptions { - callback(writeConnection) + callback(lease) } }.also { // Trigger watched queries // Fire updates inside the write lock - writeConnection.driver.fireTableUpdates() + updates.fireTableUpdates() } + } finally { + // Returning the lease will unlock the writeLockMutex + lease.close() } } override suspend fun writeLock(callback: ThrowableLockCallback): R = internalWriteLock { - callback.execute(it.driver) + callback.execute(ConnectionContextImplementation(it)) } override suspend fun writeTransaction(callback: ThrowableTransactionCallback): R = internalWriteLock { - it.transactor.transactionWithResult(noEnclosing = true) { + it.runTransaction { tx -> // Need to catch Swift exceptions here for Rollback catchSwiftExceptions { - callback.execute( - PowerSyncTransactionImpl( - it.driver, - ), - ) + callback.execute(tx) } } } // Register callback for table updates on a specific table - override fun updatesOnTables(): SharedFlow> = writeConnection.driver.updatesOnTables() + override fun updatesOnTables(): SharedFlow> = updates.updatesOnTables() // Unfortunately Errors can't be thrown from Swift SDK callbacks. // These are currently returned and should be thrown here. - private fun catchSwiftExceptions(action: () -> R): R { + private inline fun catchSwiftExceptions(action: () -> R): R { val result = action() if (result is PowerSyncException) { @@ -292,7 +335,7 @@ internal class InternalDatabaseImpl( override suspend fun close() { runWrapped { - writeConnection.driver.close() + writeConnection.close() readPool.close() } } @@ -317,26 +360,3 @@ private fun friendlyTableName(table: String): String { val match = re.matchEntire(table) ?: re2.matchEntire(table) return match?.groupValues?.get(1) ?: table } - -internal fun getBindersFromParams(parameters: List?): (SqlPreparedStatement.() -> Unit)? { - if (parameters.isNullOrEmpty()) { - return null - } - return { - parameters.forEachIndexed { index, parameter -> - when (parameter) { - is Boolean -> bindBoolean(index, parameter) - is String -> bindString(index, parameter) - is Long -> bindLong(index, parameter) - is Int -> bindLong(index, parameter.toLong()) - is Double -> bindDouble(index, parameter) - is ByteArray -> bindBytes(index, parameter) - else -> { - if (parameter != null) { - throw IllegalArgumentException("Unsupported parameter type: ${parameter::class}, at index $index") - } - } - } - } - } -} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalSchema.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalSchema.kt deleted file mode 100644 index 69f62be7..00000000 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalSchema.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.powersync.db.internal - -import app.cash.sqldelight.db.AfterVersion -import app.cash.sqldelight.db.QueryResult -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.db.SqlSchema - -internal object InternalSchema : SqlSchema> { - override val version: Long - get() = 1 - - override fun create(driver: SqlDriver): QueryResult.Value = QueryResult.Value(Unit) - - override fun migrate( - driver: SqlDriver, - oldVersion: Long, - newVersion: Long, - vararg callbacks: AfterVersion, - ): QueryResult.Value = QueryResult.Value(Unit) -} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt index 74b89eb7..7485e8ef 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt @@ -1,8 +1,75 @@ package com.powersync.db.internal +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import com.powersync.PowerSyncException +import com.powersync.db.SqlCursor + public interface PowerSyncTransaction : ConnectionContext internal class PowerSyncTransactionImpl( - context: ConnectionContext, + private val rawConnection: SQLiteConnection, ) : PowerSyncTransaction, - ConnectionContext by context + ConnectionContext { + private val delegate = ConnectionContextImplementation(rawConnection) + + private fun checkInTransaction() { + if (!rawConnection.inTransaction()) { + throw PowerSyncException("Tried executing statement on a transaction that has been rolled back", cause = null) + } + } + + override fun execute( + sql: String, + parameters: List?, + ): Long { + checkInTransaction() + return delegate.execute(sql, parameters) + } + + override fun getOptional( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType, + ): RowType? { + checkInTransaction() + return delegate.getOptional(sql, parameters, mapper) + } + + override fun getAll( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType, + ): List { + checkInTransaction() + return delegate.getAll(sql, parameters, mapper) + } + + override fun get( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType, + ): RowType { + checkInTransaction() + return delegate.get(sql, parameters, mapper) + } +} + +internal inline fun SQLiteConnection.runTransaction(cb: (PowerSyncTransaction) -> T): T { + execSQL("BEGIN") + var didComplete = false + return try { + val result = cb(PowerSyncTransactionImpl(this)) + didComplete = true + + check(inTransaction()) + execSQL("COMMIT") + result + } catch (e: Throwable) { + if (!didComplete && inTransaction()) { + execSQL("ROLLBACK") + } + + throw e + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt new file mode 100644 index 00000000..f020de45 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt @@ -0,0 +1,36 @@ +package com.powersync.db.internal + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement + +/** + * A temporary view / lease of an inner [SQLiteConnection] managed by the PowerSync SDK. + */ +internal class RawConnectionLease( + private val connection: SQLiteConnection, + private val returnConnection: () -> Unit, +) : SQLiteConnection { + private var isCompleted = false + + private fun checkNotCompleted() { + check(!isCompleted) { "Connection lease already closed" } + } + + override fun inTransaction(): Boolean { + checkNotCompleted() + return connection.inTransaction() + } + + override fun prepare(sql: String): SQLiteStatement { + checkNotCompleted() + return connection.prepare(sql) + } + + override fun close() { + // Note: This is a lease, don't close the underlying connection. + if (!isCompleted) { + isCompleted = true + returnConnection() + } + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/SqlCursorWrapper.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/SqlCursorWrapper.kt deleted file mode 100644 index bdb0c298..00000000 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/SqlCursorWrapper.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.powersync.db.internal - -import app.cash.sqldelight.db.SqlCursor -import com.powersync.persistence.driver.ColNamesSqlCursor - -internal class SqlCursorWrapper( - val realCursor: ColNamesSqlCursor, -) : com.powersync.db.SqlCursor { - override fun getBoolean(index: Int): Boolean? = realCursor.getBoolean(index) - - override fun getBytes(index: Int): ByteArray? = realCursor.getBytes(index) - - override fun getDouble(index: Int): Double? = realCursor.getDouble(index) - - override fun getLong(index: Int): Long? = realCursor.getLong(index) - - override fun getString(index: Int): String? = realCursor.getString(index) - - override fun columnName(index: Int): String? = realCursor.columnName(index) - - override val columnCount: Int - get() = realCursor.columnCount - - override val columnNames: Map by lazy { - val map = HashMap(this.columnCount) - for (i in 0 until columnCount) { - val key = columnName(i) - if (key == null) { - continue - } - if (map.containsKey(key)) { - var index = 1 - val basicKey = "$key&JOIN" - var finalKey = basicKey + index - while (map.containsKey(finalKey)) { - finalKey = basicKey + ++index - } - map[finalKey] = i - } else { - map[key] = i - } - } - map - } -} - -internal fun wrapperMapper(mapper: (com.powersync.db.SqlCursor) -> T): (SqlCursor) -> T = - { realCursor -> mapper(SqlCursorWrapper(realCursor as ColNamesSqlCursor)) } diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/TransactorDriver.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/TransactorDriver.kt deleted file mode 100644 index ee6d1efd..00000000 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/TransactorDriver.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.powersync.db.internal - -import com.powersync.PsSqlDriver -import com.powersync.persistence.PsDatabase - -/** - * Wrapper for a driver which includes a dedicated transactor. - */ -internal class TransactorDriver( - val driver: PsSqlDriver, -) { - val transactor = PsDatabase(driver) -} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt new file mode 100644 index 00000000..c7adab10 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt @@ -0,0 +1,49 @@ +package com.powersync.db.internal + +import co.touchlab.kermit.Logger +import com.powersync.internal.driver.ConnectionListener +import com.powersync.utils.AtomicMutableSet +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +internal class UpdateFlow( + private val logger: Logger, +) : ConnectionListener { + // MutableSharedFlow to emit batched table updates + private val tableUpdatesFlow = MutableSharedFlow>(replay = 0) + + // In-memory buffer to store table names before flushing + private val pendingUpdates = AtomicMutableSet() + + override fun onCommit() {} + + override fun onRollback() { + logger.v { "onRollback, clearing pending updates" } + pendingUpdates.clear() + } + + override fun onUpdate( + kind: Int, + database: String, + table: String, + rowid: Long, + ) { + pendingUpdates.add(table) + } + + // Flows on any table change + // This specifically returns a SharedFlow for downstream timing considerations + fun updatesOnTables(): SharedFlow> = + tableUpdatesFlow + .asSharedFlow() + + suspend fun fireTableUpdates() { + val updates = pendingUpdates.toSetAndClear() + if (updates.isNotEmpty()) { + logger.v { "Firing table updates for $updates" } + } + + tableUpdatesFlow.emit(updates) + } +} diff --git a/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt b/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt index 2f2c759c..6071efe6 100644 --- a/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt +++ b/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt @@ -1,7 +1,7 @@ package com.powersync -import co.touchlab.sqliter.DatabaseConnection +import com.powersync.internal.driver.NativeConnection -internal actual fun DatabaseConnection.loadPowerSyncSqliteCoreExtension() { +internal actual fun NativeConnection.loadPowerSyncSqliteCoreExtension() { loadPowerSyncSqliteCoreExtensionDynamically() } diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 39864b54..7a3efba2 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -1,22 +1,19 @@ package com.powersync -import com.powersync.db.JdbcSqliteDriver -import com.powersync.db.buildDefaultWalProperties -import com.powersync.db.internal.InternalSchema -import com.powersync.db.migrateDriver -import kotlinx.coroutines.CoroutineScope -import org.sqlite.SQLiteCommitListener +import androidx.sqlite.SQLiteConnection +import com.powersync.db.loadExtensions +import com.powersync.internal.driver.ConnectionListener +import com.powersync.internal.driver.JdbcConnection +import com.powersync.internal.driver.JdbcDriver @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") public actual class DatabaseDriverFactory { - internal actual fun createDriver( - scope: CoroutineScope, + internal actual fun openDatabase( dbFilename: String, dbDirectory: String?, readOnly: Boolean, - ): PsSqlDriver { - val schema = InternalSchema - + listener: ConnectionListener?, + ): SQLiteConnection { val dbPath = if (dbDirectory != null) { "$dbDirectory/$dbFilename" @@ -24,36 +21,13 @@ public actual class DatabaseDriverFactory { dbFilename } - val driver = - JdbcSqliteDriver( - url = "jdbc:sqlite:$dbPath", - properties = buildDefaultWalProperties(readOnly = readOnly), - ) - - migrateDriver(driver, schema) - - driver.loadExtensions( + val driver = JdbcDriver() + val connection = driver.openDatabase(dbPath, readOnly, listener) as JdbcConnection + connection.loadExtensions( powersyncExtension to "sqlite3_powersync_init", ) - val mappedDriver = PsSqlDriver(driver = driver) - - driver.connection.database.addUpdateListener { _, _, table, _ -> - mappedDriver.updateTable(table) - } - driver.connection.database.addCommitListener( - object : SQLiteCommitListener { - override fun onCommit() { - // We track transactions manually - } - - override fun onRollback() { - mappedDriver.clearTableUpdates() - } - }, - ) - - return mappedDriver + return connection } public companion object { diff --git a/core/src/macosMain/kotlin/com/powersync/DatabaseDriverFactory.macos.kt b/core/src/macosMain/kotlin/com/powersync/DatabaseDriverFactory.macos.kt index 2f2c759c..6071efe6 100644 --- a/core/src/macosMain/kotlin/com/powersync/DatabaseDriverFactory.macos.kt +++ b/core/src/macosMain/kotlin/com/powersync/DatabaseDriverFactory.macos.kt @@ -1,7 +1,7 @@ package com.powersync -import co.touchlab.sqliter.DatabaseConnection +import com.powersync.internal.driver.NativeConnection -internal actual fun DatabaseConnection.loadPowerSyncSqliteCoreExtension() { +internal actual fun NativeConnection.loadPowerSyncSqliteCoreExtension() { loadPowerSyncSqliteCoreExtensionDynamically() } diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index 69e644f0..cc7747a8 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -1,9 +1,9 @@ package com.powersync -import co.touchlab.sqliter.DatabaseConnection +import com.powersync.internal.driver.NativeConnection import com.powersync.static.powersync_init_static -internal actual fun DatabaseConnection.loadPowerSyncSqliteCoreExtension() { +internal actual fun NativeConnection.loadPowerSyncSqliteCoreExtension() { val rc = powersync_init_static() if (rc != 0) { throw PowerSyncException( diff --git a/dialect/README.md b/dialect/README.md deleted file mode 100644 index 411682f6..00000000 --- a/dialect/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# SQLDelight Custom PowerSync Dialect - -This defines the custom PowerSync SQLite functions to be used in the `PowerSync.sq` file found in the `persistence` module. - -## Example -```kotlin -public class PowerSyncTypeResolver(private val parentResolver: TypeResolver) : - TypeResolver by SqliteTypeResolver(parentResolver) { - override fun functionType(functionExpr: SqlFunctionExpr): IntermediateType? { - when (functionExpr.functionName.text) { - "powersync_replace_schema" -> return IntermediateType( - PrimitiveType.TEXT - ) - } - return parentResolver.functionType(functionExpr) - } -} -``` - -allows - -```sql -replaceSchema: -SELECT powersync_replace_schema(?); -``` - -To be used in the `PowerSync.sq` file in the `persistence` module. \ No newline at end of file diff --git a/dialect/build.gradle b/dialect/build.gradle deleted file mode 100644 index 5d9300d4..00000000 --- a/dialect/build.gradle +++ /dev/null @@ -1,21 +0,0 @@ -plugins { - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.grammarKitComposer) - alias(libs.plugins.kotlinter) -} - -grammarKit { - intellijRelease.set(libs.versions.idea) -} - -dependencies { - api(libs.sqldelight.dialect.sqlite335) - api(libs.sqldelight.dialect.sqlite338) - - compileOnly(libs.sqldelight.compilerEnv) -} - -kotlin { - jvmToolchain(17) - explicitApi() -} \ No newline at end of file diff --git a/dialect/src/main/kotlin/com/powersync/sqlite/PowerSyncDialect.kt b/dialect/src/main/kotlin/com/powersync/sqlite/PowerSyncDialect.kt deleted file mode 100644 index c9361db0..00000000 --- a/dialect/src/main/kotlin/com/powersync/sqlite/PowerSyncDialect.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.powersync.sqlite - -import app.cash.sqldelight.dialect.api.IntermediateType -import app.cash.sqldelight.dialect.api.PrimitiveType -import app.cash.sqldelight.dialect.api.SqlDelightDialect -import app.cash.sqldelight.dialect.api.TypeResolver -import app.cash.sqldelight.dialects.sqlite_3_35.SqliteTypeResolver -import com.alecstrong.sql.psi.core.psi.SqlFunctionExpr -import app.cash.sqldelight.dialects.sqlite_3_38.SqliteDialect as Sqlite338Dialect - -public class PowerSyncDialect : SqlDelightDialect by Sqlite338Dialect() { - override fun typeResolver(parentResolver: TypeResolver): PowerSyncTypeResolver = PowerSyncTypeResolver(parentResolver) -} - -public class PowerSyncTypeResolver( - private val parentResolver: TypeResolver, -) : TypeResolver by SqliteTypeResolver(parentResolver) { - override fun functionType(functionExpr: SqlFunctionExpr): IntermediateType? { - when (functionExpr.functionName.text) { - "sqlite_version", - "powersync_rs_version", - "powersync_replace_schema", - "powersync_clear", - "powersync_init", - -> return IntermediateType( - PrimitiveType.TEXT, - ) - } - return parentResolver.functionType(functionExpr) - } -} diff --git a/dialect/src/main/resources/META-INF/services/app.cash.sqldelight.dialect.api.SqlDelightDialect b/dialect/src/main/resources/META-INF/services/app.cash.sqldelight.dialect.api.SqlDelightDialect deleted file mode 100644 index 2d4118ed..00000000 --- a/dialect/src/main/resources/META-INF/services/app.cash.sqldelight.dialect.api.SqlDelightDialect +++ /dev/null @@ -1 +0,0 @@ -com.powersync.sqlite.PowerSyncDialect diff --git a/drivers/README.md b/drivers/README.md new file mode 100644 index 00000000..d1d8f3f9 --- /dev/null +++ b/drivers/README.md @@ -0,0 +1,3 @@ +Internal drivers for SQLite. + +These projects are currently internal to the PowerSync SDK and should not be depended on directly. diff --git a/drivers/common/build.gradle.kts b/drivers/common/build.gradle.kts new file mode 100644 index 00000000..1c55c497 --- /dev/null +++ b/drivers/common/build.gradle.kts @@ -0,0 +1,62 @@ +import com.powersync.plugins.utils.powersyncTargets + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinter) + id("com.powersync.plugins.sonatype") +} + +kotlin { + powersyncTargets() + explicitApi() + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain.dependencies { + api(libs.androidx.sqlite) + } + + val commonJava by creating { + dependsOn(commonMain.get()) + dependencies { + implementation(libs.sqlite.jdbc) + } + } + + jvmMain { + dependsOn(commonJava) + } + + androidMain { + dependsOn(commonJava) + } + + nativeMain.dependencies { + implementation(libs.androidx.sqliteFramework) + } + + all { + languageSettings { + optIn("kotlinx.cinterop.ExperimentalForeignApi") + } + } + } +} + +android { + namespace = "com.powersync.drivers.common" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + defaultConfig { + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + } + kotlin { + jvmToolchain(17) + } +} diff --git a/drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt b/drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt new file mode 100644 index 00000000..44bd6609 --- /dev/null +++ b/drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt @@ -0,0 +1,26 @@ +package com.powersync.internal.driver + +import android.content.Context +import java.util.Properties +import java.util.concurrent.atomic.AtomicBoolean + +public class AndroidDriver( + private val context: Context, +) : JdbcDriver() { + override fun addDefaultProperties(properties: Properties) { + val isFirst = IS_FIRST_CONNECTION.getAndSet(false) + if (isFirst) { + // Make sure the temp_store_directory points towards a temporary directory we actually + // have access to. Due to sandboxing, the default /tmp/ is inaccessible. + // The temp_store_directory pragma is deprecated and not thread-safe, so we only set it + // on the first connection (it sets a global field and will affect every connection + // opened). + val escapedPath = context.cacheDir.absolutePath.replace("\"", "\"\"") + properties.setProperty("temp_store_directory", "\"$escapedPath\"") + } + } + + private companion object { + val IS_FIRST_CONNECTION = AtomicBoolean(true) + } +} diff --git a/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt b/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt new file mode 100644 index 00000000..42206221 --- /dev/null +++ b/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt @@ -0,0 +1,179 @@ +package com.powersync.internal.driver + +import androidx.sqlite.SQLITE_DATA_NULL +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import org.sqlite.SQLiteCommitListener +import org.sqlite.SQLiteConfig +import org.sqlite.SQLiteOpenMode +import org.sqlite.SQLiteUpdateListener +import org.sqlite.jdbc4.JDBC4Connection +import org.sqlite.jdbc4.JDBC4PreparedStatement +import org.sqlite.jdbc4.JDBC4ResultSet +import java.sql.Types +import java.util.Properties + +public open class JdbcDriver : PowerSyncDriver { + internal open fun addDefaultProperties(properties: Properties) {} + + override fun openDatabase( + path: String, + readOnly: Boolean, + listener: ConnectionListener?, + ): SQLiteConnection { + val properties = + Properties().also { + it.setProperty( + SQLiteConfig.Pragma.OPEN_MODE.pragmaName, + if (readOnly) { + SQLiteOpenMode.READONLY.flag + } else { + SQLiteOpenMode.READWRITE.flag or SQLiteOpenMode.CREATE.flag + }.toString(), + ) + } + + val inner = JDBC4Connection(path, path, properties) + listener?.let { + inner.addCommitListener( + object : SQLiteCommitListener { + override fun onCommit() { + it.onCommit() + } + + override fun onRollback() { + it.onRollback() + } + }, + ) + + inner.addUpdateListener { type, database, table, rowId -> + val flags = + when (type) { + SQLiteUpdateListener.Type.INSERT -> SQLITE_INSERT + SQLiteUpdateListener.Type.DELETE -> SQLITE_DELETE + SQLiteUpdateListener.Type.UPDATE -> SQLITE_UPDATE + } + + it.onUpdate(flags, database, table, rowId) + } + } + + return JdbcConnection(inner) + } + + private companion object { + const val SQLITE_DELETE: Int = 9 + const val SQLITE_INSERT: Int = 18 + const val SQLITE_UPDATE: Int = 23 + } +} + +public class JdbcConnection( + public val connection: org.sqlite.SQLiteConnection, +) : SQLiteConnection { + override fun inTransaction(): Boolean { + // TODO: Unsupported with sqlite-jdbc? + return true + } + + override fun prepare(sql: String): SQLiteStatement = PowerSyncStatement(connection.prepareStatement(sql) as JDBC4PreparedStatement) + + override fun close() { + connection.close() + } +} + +private class PowerSyncStatement( + private val stmt: JDBC4PreparedStatement, +) : SQLiteStatement { + private var currentCursor: JDBC4ResultSet? = null + + private val _columnCount: Int by lazy { + // We have to call this manually because stmt.metadata.columnCount throws an exception when + // a statement has zero columns. + stmt.pointer.safeRunInt { db, ptr -> db.column_count(ptr) } + } + + private fun requireCursor(): JDBC4ResultSet = + requireNotNull(currentCursor) { + "Illegal call which requires cursor, step() hasn't been called" + } + + override fun bindBlob( + index: Int, + value: ByteArray, + ) { + stmt.setBytes(index, value) + } + + override fun bindDouble( + index: Int, + value: Double, + ) { + stmt.setDouble(index, value) + } + + override fun bindLong( + index: Int, + value: Long, + ) { + stmt.setLong(index, value) + } + + override fun bindText( + index: Int, + value: String, + ) { + stmt.setString(index, value) + } + + override fun bindNull(index: Int) { + stmt.setNull(index, Types.NULL) + } + + override fun getBlob(index: Int): ByteArray = requireCursor().getBytes(index + 1) + + override fun getDouble(index: Int): Double = requireCursor().getDouble(index + 1) + + override fun getLong(index: Int): Long = requireCursor().getLong(index + 1) + + override fun getText(index: Int): String = requireCursor().getString(index + 1) + + override fun isNull(index: Int): Boolean = getColumnType(index) == SQLITE_DATA_NULL + + override fun getColumnCount(): Int = _columnCount + + override fun getColumnName(index: Int): String = stmt.metaData.getColumnName(index + 1) + + override fun getColumnType(index: Int): Int = stmt.pointer.safeRunInt { db, ptr -> db.column_type(ptr, index) } + + override fun step(): Boolean { + if (currentCursor == null) { + if (_columnCount == 0) { + // sqlite-jdbc refuses executeQuery calls for statements that don't return results + stmt.execute() + return false + } else { + currentCursor = stmt.executeQuery() as JDBC4ResultSet + } + } + + return currentCursor!!.next() + } + + override fun reset() { + currentCursor?.close() + currentCursor = null + } + + override fun clearBindings() { + stmt.clearParameters() + } + + override fun close() { + currentCursor?.close() + currentCursor = null + stmt.close() + } +} diff --git a/drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt b/drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt new file mode 100644 index 00000000..4baa7535 --- /dev/null +++ b/drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt @@ -0,0 +1,31 @@ +package com.powersync.internal.driver + +import androidx.sqlite.SQLiteConnection + +/** + * An internal interface to open a SQLite connection that has the PowerSync core extension loaded. + */ +public interface PowerSyncDriver { + /** + * Opens a database at [path], without initializing the PowerSync core extension or running any + * pragma statements that require the database to be accessible. + */ + public fun openDatabase( + path: String, + readOnly: Boolean = false, + listener: ConnectionListener? = null, + ): SQLiteConnection +} + +public interface ConnectionListener { + public fun onCommit() + + public fun onRollback() + + public fun onUpdate( + kind: Int, + database: String, + table: String, + rowid: Long, + ) +} diff --git a/drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt b/drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt new file mode 100644 index 00000000..581d5e8f --- /dev/null +++ b/drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt @@ -0,0 +1,111 @@ +package com.powersync.internal.driver + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import androidx.sqlite.driver.NativeSQLiteConnection +import androidx.sqlite.throwSQLiteException +import cnames.structs.sqlite3 +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.StableRef +import kotlinx.cinterop.allocPointerTo +import kotlinx.cinterop.asStableRef +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.staticCFunction +import kotlinx.cinterop.toKString +import kotlinx.cinterop.value +import sqlite3.SQLITE_OPEN_CREATE +import sqlite3.SQLITE_OPEN_READONLY +import sqlite3.SQLITE_OPEN_READWRITE +import sqlite3.sqlite3_commit_hook +import sqlite3.sqlite3_open_v2 +import sqlite3.sqlite3_rollback_hook +import sqlite3.sqlite3_update_hook + +public class NativeDriver : PowerSyncDriver { + override fun openDatabase( + path: String, + readOnly: Boolean, + listener: ConnectionListener?, + ): SQLiteConnection = openNativeDatabase(path, readOnly, listener) + + public fun openNativeDatabase( + path: String, + readOnly: Boolean, + listener: ConnectionListener?, + ): NativeConnection { + val flags = + if (readOnly) { + SQLITE_OPEN_READONLY + } else { + SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE + } + + return memScoped { + val dbPointer = allocPointerTo() + val resultCode = + sqlite3_open_v2(filename = path, ppDb = dbPointer.ptr, flags = flags, zVfs = null) + + if (resultCode != 0) { + throwSQLiteException(resultCode, null) + } + + NativeConnection(dbPointer.value!!, listener) + } + } +} + +public class NativeConnection( + public val sqlite: CPointer, + listener: ConnectionListener?, +) : SQLiteConnection { + private val inner: NativeSQLiteConnection = NativeSQLiteConnection(sqlite) + private val listener: StableRef? = + listener?.let { StableRef.create(it) }?.also { + sqlite3_update_hook(sqlite, updateHook, it.asCPointer()) + sqlite3_commit_hook(sqlite, commitHook, it.asCPointer()) + sqlite3_rollback_hook(sqlite, rollbackHook, it.asCPointer()) + } + + override fun inTransaction(): Boolean = inner.inTransaction() + + override fun prepare(sql: String): SQLiteStatement = inner.prepare(sql) + + override fun close() { + inner.close() + listener?.dispose() + } +} + +private val commitHook = + staticCFunction { + val listener = it!!.asStableRef().get() + listener.onCommit() + 0 + } + +private val rollbackHook = + staticCFunction { + val listener = it!!.asStableRef().get() + listener.onRollback() + } + +private val updateHook = + staticCFunction< + COpaquePointer?, + Int, + CPointer?, + CPointer?, + Long, + Unit, + > { ctx, type, db, table, rowId -> + val listener = ctx!!.asStableRef().get() + listener.onUpdate( + type, + db!!.toKString(), + table!!.toKString(), + rowId, + ) + } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0551878..ea44892b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,11 +7,11 @@ configurationAnnotations = "0.9.5" dokkaBase = "2.0.0" gradleDownloadTask = "5.5.0" java = "17" -idea = "243.22562.218" # Meerkat | 2024.3.1 (see https://plugins.jetbrains.com/docs/intellij/android-studio-releases-list.html) # Dependencies kermit = "2.0.5" kotlin = "2.1.21" +ksp = "2.1.21-2.0.2" # Note: Always keep the first part in sync with the Kotlin version coroutines = "1.8.1" kotlinx-datetime = "0.6.2" kotlinx-io = "0.5.4" @@ -20,25 +20,23 @@ rsocket = "0.20.0" uuid = "0.8.2" powersync-core = "0.4.2" sqlite-jdbc = "3.50.3.0" -sqliter = "1.3.1" turbine = "1.2.0" kotest = "5.9.1" -sqlDelight = "2.0.2" stately = "2.1.0" supabase = "3.0.1" junit = "4.13.2" compose = "1.6.11" compose-preview = "1.7.8" -androidxSqlite = "2.4.0" +androidxSqlite = "2.6.0-alpha01" +room = "2.7.2" # plugins android-gradle-plugin = "8.10.1" skie = "0.10.2" maven-publish = "0.27.0" download-plugin = "5.5.0" -grammarkit-composer = "0.1.12" mokkery = "2.8.0" kotlinter = "5.0.1" keeper = "0.16.1" @@ -90,24 +88,20 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c rsocket-core = { module = "io.rsocket.kotlin:rsocket-core", version.ref = "rsocket" } rsocket-transport-websocket = { module = "io.rsocket.kotlin:rsocket-transport-ktor-websocket-internal", version.ref = "rsocket" } -sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } -sqliter = { module = "co.touchlab:sqliter-driver", version.ref = "sqliter" } -sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } -sqldelight-driver-jdbc = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqlDelight" } -sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqlDelight" } -sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlDelight" } -sqldelight-dialect-sqlite338 = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version.ref = "sqlDelight" } -sqldelight-dialect-sqlite335 = { module = "app.cash.sqldelight:sqlite-3-35-dialect", version.ref = "sqlDelight" } -sqldelight-compilerEnv = { module = "app.cash.sqldelight:compiler-env", version.ref = "sqlDelight" } - sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } stately-concurrency = { module = "co.touchlab:stately-concurrency", version.ref = "stately" } supabase-client = { module = "io.github.jan-tennert.supabase:postgrest-kt", version.ref = "supabase" } supabase-auth = { module = "io.github.jan-tennert.supabase:auth-kt", version.ref = "supabase" } supabase-storage = { module = "io.github.jan-tennert.supabase:storage-kt", version.ref = "supabase" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "androidxSqlite" } androidx-sqliteFramework = { module = "androidx.sqlite:sqlite-framework", version.ref = "androidxSqlite" } +# Room integration +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } + # Sample - Android androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } @@ -127,8 +121,6 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } skie = { id = "co.touchlab.skie", version.ref = "skie" } -sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" } -grammarKitComposer = { id = "com.alecstrong.grammar.kit.composer", version.ref = "grammarkit-composer" } mavenPublishPlugin = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } downloadPlugin = { id = "de.undercouch.download", version.ref = "download-plugin" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" } @@ -136,9 +128,4 @@ kotlinter = { id = "org.jmailen.kotlinter", version.ref = "kotlinter" } keeper = { id = "com.slack.keeper", version.ref = "keeper" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } - -[bundles] -sqldelight = [ - "sqldelight-runtime", - "sqldelight-coroutines" -] +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/integrations/room/README.md b/integrations/room/README.md new file mode 100644 index 00000000..2c649140 --- /dev/null +++ b/integrations/room/README.md @@ -0,0 +1,9 @@ +# Room integration for PowerSync + +This package enables PowerSync for [Room](https://developer.android.com/training/data-storage/room) +databases, allowing you to define your queries in a type-safe way. +Watched Room queries automatically update when new data gets synced by PowerSync. + +Note that this package is currently in _alpha_, and breaking changes are still expected. +It is tested however, and we encourage interested users to try it out! + diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts new file mode 100644 index 00000000..026088ca --- /dev/null +++ b/integrations/room/build.gradle.kts @@ -0,0 +1,64 @@ +import com.powersync.plugins.utils.powersyncTargets + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinter) + alias(libs.plugins.ksp) + id("com.powersync.plugins.sonatype") +} + +kotlin { + powersyncTargets(watchOS=false) + explicitApi() + applyDefaultHierarchyTemplate() + + sourceSets { + all { + languageSettings { + optIn("com.powersync.ExperimentalPowerSyncAPI") + } + } + + commonMain.dependencies { + api(projects.core) + + api(libs.androidx.sqlite) + api(libs.androidx.room.runtime) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.io) + implementation(libs.test.kotest.assertions) + implementation(libs.test.coroutines) + implementation(libs.test.turbine) + } + } +} + +dependencies { + // We use a room database for testing, so we apply the symbol processor on the test target. + listOf("jvm", "macosArm64", "macosX64", "iosSimulatorArm64", "iosX64").forEach { target -> + val capitalized = target.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + + add("ksp${capitalized}Test", libs.androidx.room.compiler) + } +} + +android { + namespace = "com.powersync.integrations.room" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + defaultConfig { + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + } + kotlin { + jvmToolchain(17) + } +} diff --git a/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/TestUtils.android.kt b/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/TestUtils.android.kt new file mode 100644 index 00000000..37c9c3f5 --- /dev/null +++ b/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/TestUtils.android.kt @@ -0,0 +1,10 @@ +package com.powersync.integrations.room + +import androidx.room.RoomDatabase + +actual val factory: com.powersync.DatabaseDriverFactory + get() = throw UnsupportedOperationException("Android unit tests are not supported") + +actual fun databaseBuilder(): RoomDatabase.Builder { + throw UnsupportedOperationException("Android unit tests are not supported") +} diff --git a/integrations/room/src/appleTest/kotlin/com/powersync/integrations/room/TestUtils.apple.kt b/integrations/room/src/appleTest/kotlin/com/powersync/integrations/room/TestUtils.apple.kt new file mode 100644 index 00000000..89913eee --- /dev/null +++ b/integrations/room/src/appleTest/kotlin/com/powersync/integrations/room/TestUtils.apple.kt @@ -0,0 +1,16 @@ +package com.powersync.integrations.room + +import androidx.room.Room +import androidx.room.RoomDatabase +import com.powersync.DatabaseDriverFactory + +actual val factory: DatabaseDriverFactory + get() = DatabaseDriverFactory() + +actual fun databaseBuilder(): RoomDatabase.Builder { + return Room.databaseBuilder("TestDatabase") { + AppDatabaseForPowerSync( + createTodosDao = { TodosDao_Impl(it) } + ) + } +} diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/Driver.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/Driver.kt new file mode 100644 index 00000000..e3a550ba --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/Driver.kt @@ -0,0 +1,162 @@ +package com.powersync.integrations.room + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import com.powersync.ExperimentalPowerSyncAPI +import com.powersync.PowerSyncDatabase +import kotlinx.coroutines.runBlocking + +/** + * A [SQLiteDriver] that is backed by an existing [PowerSyncDatabase]. + * + * In [open], the `fileName` parameter will always be ignored. Instead, all connections are + * implemented by temporarily leasing a connection from the connection pool managed by PowerSync. + */ +public class PowerSyncRoomDriver( + private val db: PowerSyncDatabase, +): SQLiteDriver { + override val hasConnectionPool: Boolean + // The PowerSync database has a connection pool internally, so Room shouldn't roll its own. + get() = true + + override fun open(fileName: String): SQLiteConnection { + return PowerSyncConnection(db) + } +} + +private class PowerSyncConnection( + private val db: PowerSyncDatabase, +): SQLiteConnection { + // We lazily request an underlying SQLite connection when necessary, and release it as quickly + // as possible so that other concurrent PowerSync operations can run. + private var currentLease: SQLiteConnection? = null + private var inTransaction = false + + @OptIn(ExperimentalPowerSyncAPI::class) + private fun obtainConnection(): SQLiteConnection { + currentLease?.let { return it } + + return runBlocking { db.leaseConnection(readOnly = false) }.also { + currentLease = it + } + } + + private fun returnConnection() { + currentLease?.close() + currentLease = null + } + + override fun prepare(sql: String): SQLiteStatement { + val lower = sql.lowercase() + if (ignoredPragma.matches(lower)) { + // PowerSync actually uses custom pragmas + return FakeStatement(sql) + } + + val connection = obtainConnection() + // TODO: If we had a reliable way to get the autocommit state (we don't have it for + // sqlite-jdbc), we could remove this hacky check. + if (lower.startsWith("begin")) { + inTransaction = true + } + if (lower.startsWith("end transaction") || lower.startsWith("rollback")) { + inTransaction = false + } + + return CompletableStatement(connection.prepare(sql)) { + if (!inTransaction) { + returnConnection() + } + } + } + + override fun close() { + returnConnection() + } + + private companion object { + val ignoredPragma = Regex("pragma .*=.*") + } +} + +private class CompletableStatement( + private val stmt: SQLiteStatement, + private val onClose: () -> Unit, +): SQLiteStatement by stmt { + override fun close() { + stmt.close() + onClose() + } +} + +private class FakeStatement(val sql: String): SQLiteStatement { + private fun stub(): Nothing { + throw UnsupportedOperationException("Fake statement: $sql") + } + + override fun bindBlob(index: Int, value: ByteArray) { + } + + override fun bindDouble(index: Int, value: Double) { + + } + + override fun bindLong(index: Int, value: Long) { + } + + override fun bindText(index: Int, value: String) { + } + + override fun bindNull(index: Int) { + } + + override fun getBlob(index: Int): ByteArray { + stub() + } + + override fun getDouble(index: Int): Double { + stub() + } + + override fun getLong(index: Int): Long { + // make pragma user_version return 1 so that room doesn't try to migrate. + return 1L + } + + override fun getText(index: Int): String { + stub() + } + + override fun isNull(index: Int): Boolean { + stub() + } + + override fun getColumnCount(): Int { + stub() + } + + override fun getColumnName(index: Int): String { + stub() + } + + override fun getColumnType(index: Int): Int { + stub() + } + + override fun step(): Boolean { + return false + } + + override fun reset() { + + } + + override fun clearBindings() { + + } + + override fun close() { + + } +} diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncInvalidationTracker.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncInvalidationTracker.kt new file mode 100644 index 00000000..1c900f71 --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncInvalidationTracker.kt @@ -0,0 +1,6 @@ +package com.powersync.integrations.room + +import androidx.room.InvalidationTracker + +public class PowerSyncInvalidationTracker: InvalidationTracker() { +} \ No newline at end of file diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncOpenDelegate.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncOpenDelegate.kt new file mode 100644 index 00000000..78b7bbc1 --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncOpenDelegate.kt @@ -0,0 +1,31 @@ +package com.powersync.integrations.room + +import androidx.room.RoomOpenDelegate +import androidx.sqlite.SQLiteConnection + +public class PowerSyncOpenDelegate(): RoomOpenDelegate(1, "", "") { + override fun onCreate(connection: SQLiteConnection) { + } + + override fun onPreMigrate(connection: SQLiteConnection) { + } + + override fun onValidateSchema(connection: SQLiteConnection): ValidationResult { + return ValidationResult(true, null) + } + + override fun onPostMigrate(connection: SQLiteConnection) { + } + + override fun onOpen(connection: SQLiteConnection) { + + } + + override fun createAllTables(connection: SQLiteConnection) { + + } + + override fun dropAllTables(connection: SQLiteConnection) { + } + +} \ No newline at end of file diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/DriverTest.kt b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/DriverTest.kt new file mode 100644 index 00000000..a1d76ba3 --- /dev/null +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/DriverTest.kt @@ -0,0 +1,47 @@ +package com.powersync.integrations.room + +import com.powersync.PowerSyncDatabase +import com.powersync.db.schema.Schema +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.files.SystemTemporaryDirectory +import kotlin.test.Test + +class DriverTest { + @Test + fun usingRoomApis() = databaseTest { db -> + val room = databaseBuilder().setDriver(PowerSyncRoomDriver(db)).build() + + room.todosDao().count() shouldBe 0 + room.todosDao().addEntry(TodoEntity(title="Title", content="content")) + room.todosDao().count() shouldBe 1 + room.close() + } +} + +private fun databaseTest(body: suspend TestScope.(PowerSyncDatabase) -> Unit) { + runTest { + val dir = SystemTemporaryDirectory + + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + val suffix = CharArray(8) { allowedChars.random() }.concatToString() + val databaseName = "db-$suffix" + + val db = PowerSyncDatabase( + factory, + schema = Schema(listOf(TodoEntity.TABLE)), + dbFilename = databaseName, + dbDirectory = dir.toString() + ) + + try { + body(db) + } finally { + db.close() + SystemFileSystem.delete(Path(dir, databaseName)) + } + } +} diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt new file mode 100644 index 00000000..24d2ebcf --- /dev/null +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt @@ -0,0 +1,72 @@ +package com.powersync.integrations.room + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.InvalidationTracker +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.RoomDatabase +import androidx.room.RoomOpenDelegateMarker +import com.powersync.db.schema.Column +import com.powersync.db.schema.Table +import kotlinx.coroutines.flow.Flow +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Database( + entities = [TodoEntity::class], + version = 1, + exportSchema = false, +) +abstract class AppDatabase(): RoomDatabase() { + abstract fun todosDao(): TodosDao +} + +class AppDatabaseForPowerSync( + createTodosDao: (AppDatabase) -> TodosDao +): AppDatabase() { + private val _todosDao by lazy { createTodosDao(this) } + + override fun todosDao(): TodosDao = _todosDao + + override fun createOpenDelegate(): RoomOpenDelegateMarker { + return PowerSyncOpenDelegate() + } + + override fun createInvalidationTracker(): InvalidationTracker { + TODO("Not yet implemented") + } +} + +@Dao +interface TodosDao { + @Insert + suspend fun addEntry(entry: TodoEntity) + + @Query("SELECT count(*) FROM todos") + suspend fun count(): Int + + @Query("SELECT * FROM todos") + fun all(): Flow> +} + +@OptIn(ExperimentalUuidApi::class) +@Entity(tableName = "todos") +data class TodoEntity( + @PrimaryKey + val id: String = Uuid.random().toHexDashString(), + val title: String, + val content: String, +) { + companion object { + val TABLE = Table( + name = "todos", + columns = listOf( + Column.text("title"), + Column.text("content"), + ) + ) + } +} diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestUtils.kt b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestUtils.kt new file mode 100644 index 00000000..679c4fc1 --- /dev/null +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestUtils.kt @@ -0,0 +1,8 @@ +package com.powersync.integrations.room + +import androidx.room.RoomDatabase +import com.powersync.DatabaseDriverFactory + +expect val factory: DatabaseDriverFactory + +expect fun databaseBuilder(): RoomDatabase.Builder diff --git a/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/TestUtils.jvm.kt b/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/TestUtils.jvm.kt new file mode 100644 index 00000000..89913eee --- /dev/null +++ b/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/TestUtils.jvm.kt @@ -0,0 +1,16 @@ +package com.powersync.integrations.room + +import androidx.room.Room +import androidx.room.RoomDatabase +import com.powersync.DatabaseDriverFactory + +actual val factory: DatabaseDriverFactory + get() = DatabaseDriverFactory() + +actual fun databaseBuilder(): RoomDatabase.Builder { + return Room.databaseBuilder("TestDatabase") { + AppDatabaseForPowerSync( + createTodosDao = { TodosDao_Impl(it) } + ) + } +} diff --git a/persistence/.gitignore b/persistence/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/persistence/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/persistence/build.gradle.kts b/persistence/build.gradle.kts deleted file mode 100644 index 69eaafb8..00000000 --- a/persistence/build.gradle.kts +++ /dev/null @@ -1,90 +0,0 @@ -import com.powersync.plugins.sonatype.setupGithubRepository -import com.powersync.plugins.utils.powersyncTargets -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.sqldelight) - alias(libs.plugins.androidLibrary) - alias(libs.plugins.kotlinter) - id("com.powersync.plugins.sonatype") -} - -kotlin { - powersyncTargets() - - explicitApi() - - sourceSets { - commonMain.dependencies { - api(libs.bundles.sqldelight) - } - - androidMain.dependencies { - api(libs.powersync.sqlite.core.android) - implementation(libs.androidx.sqliteFramework) - } - - jvmMain.dependencies { - api(libs.sqldelight.driver.jdbc) - } - - appleMain.dependencies { - api(libs.sqldelight.driver.native) - api(projects.staticSqliteDriver) - } - } -} - -android { - compileOptions { - targetCompatibility = JavaVersion.VERSION_17 - } - - buildFeatures { - buildConfig = true - } - - buildTypes { - release { - buildConfigField("boolean", "DEBUG", "false") - } - debug { - buildConfigField("boolean", "DEBUG", "true") - } - } - defaultConfig { - minSdk = - libs.versions.android.minSdk - .get() - .toInt() - } - - namespace = "com.powersync.persistence" - compileSdk = - libs.versions.android.compileSdk - .get() - .toInt() -} - -sqldelight { - linkSqlite = false - - databases { - create("PsDatabase") { - packageName.set("com.powersync.persistence") - dialect(project(":dialect")) - } - } -} - -tasks.formatKotlinCommonMain { - exclude { it.file.path.contains("generated/") } -} - -tasks.lintKotlinCommonMain { - exclude { it.file.path.contains("generated/") } -} - -setupGithubRepository() diff --git a/persistence/gradle.properties b/persistence/gradle.properties deleted file mode 100644 index 652fb955..00000000 --- a/persistence/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_ARTIFACT_ID=persistence -POM_NAME=SqlDelight Persistence -POM_DESCRIPTION=SqlDelight database setup used in the core package. \ No newline at end of file diff --git a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/Borrowed.kt b/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/Borrowed.kt deleted file mode 100644 index e139e920..00000000 --- a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/Borrowed.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.powersync.persistence.driver - -internal interface Borrowed { - val value: T - - fun release() -} diff --git a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/NativeSqlDatabase.kt b/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/NativeSqlDatabase.kt deleted file mode 100644 index 3c4c8b35..00000000 --- a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/NativeSqlDatabase.kt +++ /dev/null @@ -1,459 +0,0 @@ -package com.powersync.persistence.driver - -import app.cash.sqldelight.Query -import app.cash.sqldelight.Transacter -import app.cash.sqldelight.db.AfterVersion -import app.cash.sqldelight.db.Closeable -import app.cash.sqldelight.db.QueryResult -import app.cash.sqldelight.db.SqlCursor -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.db.SqlPreparedStatement -import app.cash.sqldelight.db.SqlSchema -import co.touchlab.sqliter.DatabaseConfiguration -import co.touchlab.sqliter.DatabaseConnection -import co.touchlab.sqliter.DatabaseManager -import co.touchlab.sqliter.Statement -import co.touchlab.sqliter.createDatabaseManager -import co.touchlab.sqliter.withStatement -import co.touchlab.stately.concurrency.ThreadLocalRef -import co.touchlab.stately.concurrency.value -import com.powersync.persistence.driver.util.PoolLock - -public sealed class ConnectionWrapper : SqlDriver { - internal abstract fun accessConnection( - readOnly: Boolean, - block: ThreadConnection.() -> R, - ): R - - private fun accessStatement( - readOnly: Boolean, - identifier: Int?, - sql: String, - binders: (SqlPreparedStatement.() -> Unit)?, - block: (Statement) -> R, - ): R = - accessConnection(readOnly) { - val statement = useStatement(identifier, sql) - try { - if (binders != null) { - SqliterStatement(statement).binders() - } - block(statement) - } finally { - statement.resetStatement() - clearIfNeeded(identifier, statement) - } - } - - final override fun execute( - identifier: Int?, - sql: String, - parameters: Int, - binders: (SqlPreparedStatement.() -> Unit)?, - ): QueryResult = - QueryResult.Value( - accessStatement(false, identifier, sql, binders) { statement -> - statement.executeUpdateDelete().toLong() - }, - ) - - final override fun executeQuery( - identifier: Int?, - sql: String, - mapper: (SqlCursor) -> QueryResult, - parameters: Int, - binders: (SqlPreparedStatement.() -> Unit)?, - ): QueryResult = - accessStatement(true, identifier, sql, binders) { statement -> - mapper(SqliterSqlCursor(statement.query())) - } -} - -/** - * Native driver implementation. - * - * The driver creates two connection pools, which default to 1 connection maximum. There is a reader pool, which - * handles all query requests outside of a transaction. The other pool is the transaction pool, which handles - * all transactions and write requests outside of a transaction. - * - * When a transaction is started, that thread is aligned with a transaction pool connection. Attempting a write or - * starting another transaction, if no connections are available, will cause the caller to wait. - * - * You can have multiple connections in the transaction pool, but this would only be useful for read transactions. Writing - * from multiple connections in an overlapping manner can be problematic. - * - * Aligning a transaction to a thread means you cannot operate on a single transaction from multiple threads. - * However, it would be difficult to find a use case where this would be desirable or safe. Currently, the native - * implementation of kotlinx.coroutines does not use thread pooling. When that changes, we'll need a way to handle - * transaction/connection alignment similar to what the Android/JVM driver implemented. - * - * https://medium.com/androiddevelopers/threading-models-in-coroutines-and-android-sqlite-api-6cab11f7eb90 - * - * To use SqlDelight during create/upgrade processes, you can alternatively wrap a real connection - * with wrapConnection. - * - * SqlPreparedStatement instances also do not point to real resources until either execute or - * executeQuery is called. The SqlPreparedStatement structure also maintains a thread-aligned - * instance which accumulates bind calls. Those are replayed on a real SQLite statement instance - * when execute or executeQuery is called. This avoids race conditions with bind calls. - */ -public class NativeSqliteDriver( - private val databaseManager: DatabaseManager, - maxReaderConnections: Int = 1, -) : ConnectionWrapper(), - SqlDriver { - public constructor( - configuration: DatabaseConfiguration, - maxReaderConnections: Int = 1, - ) : this( - databaseManager = createDatabaseManager(configuration), - maxReaderConnections = maxReaderConnections, - ) - - /** - * @param onConfiguration Callback to hook into [DatabaseConfiguration] creation. - */ - public constructor( - schema: SqlSchema>, - name: String, - maxReaderConnections: Int = 1, - onConfiguration: (DatabaseConfiguration) -> DatabaseConfiguration = { it }, - vararg callbacks: AfterVersion, - ) : this( - configuration = - DatabaseConfiguration( - name = name, - version = - if (schema.version > - Int.MAX_VALUE - ) { - error("Schema version is larger than Int.MAX_VALUE: ${schema.version}.") - } else { - schema.version.toInt() - }, - create = { connection -> wrapConnection(connection) { schema.create(it) } }, - upgrade = { connection, oldVersion, newVersion -> - wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong(), *callbacks) } - }, - ).let(onConfiguration), - maxReaderConnections = maxReaderConnections, - ) - - // A pool of reader connections used by all operations not in a transaction - private val transactionPool: Pool - internal val readerPool: Pool - - // Once a transaction is started and connection borrowed, it will be here, but only for that - // thread - private val borrowedConnectionThread = ThreadLocalRef>() - private val listeners = mutableMapOf>() - private val lock = PoolLock(reentrant = true) - - init { - if (databaseManager.configuration.isEphemeral) { - // Single connection for transactions - transactionPool = - Pool(1) { - ThreadConnection(databaseManager.createMultiThreadedConnection()) { _ -> - borrowedConnectionThread.let { - it.get()?.release() - it.value = null - } - } - } - - readerPool = transactionPool - } else { - // Single connection for transactions - transactionPool = - Pool(1) { - ThreadConnection(databaseManager.createMultiThreadedConnection()) { _ -> - borrowedConnectionThread.let { - it.get()?.release() - it.value = null - } - } - } - - readerPool = - Pool(maxReaderConnections) { - val connection = databaseManager.createMultiThreadedConnection() - connection.withStatement("PRAGMA query_only = 1") { execute() } // Ensure read only - ThreadConnection(connection) { - throw UnsupportedOperationException("Should never be in a transaction") - } - } - } - } - - override fun addListener( - vararg queryKeys: String, - listener: Query.Listener, - ) { - lock.withLock { - queryKeys.forEach { - listeners.getOrPut(it) { mutableSetOf() }.add(listener) - } - } - } - - override fun removeListener( - vararg queryKeys: String, - listener: Query.Listener, - ) { - lock.withLock { - queryKeys.forEach { - listeners.get(it)?.remove(listener) - } - } - } - - override fun notifyListeners(vararg queryKeys: String) { - val listenersToNotify = mutableSetOf() - lock.withLock { - queryKeys.forEach { key -> listeners.get(key)?.let { listenersToNotify.addAll(it) } } - } - listenersToNotify.forEach(Query.Listener::queryResultsChanged) - } - - override fun currentTransaction(): Transacter.Transaction? = - borrowedConnectionThread - .get() - ?.value - ?.transaction - ?.value - - override fun newTransaction(): QueryResult { - val alreadyBorrowed = borrowedConnectionThread.get() - val transaction = - if (alreadyBorrowed == null) { - val borrowed = transactionPool.borrowEntry() - - try { - val trans = borrowed.value.newTransaction() - - borrowedConnectionThread.value = borrowed - trans - } catch (e: Throwable) { - // Unlock on failure. - borrowed.release() - throw e - } - } else { - alreadyBorrowed.value.newTransaction() - } - - return QueryResult.Value(transaction) - } - - /** - * If we're in a transaction, then I have a connection. Otherwise use shared. - */ - override fun accessConnection( - readOnly: Boolean, - block: ThreadConnection.() -> R, - ): R { - val mine = borrowedConnectionThread.get() - return if (readOnly) { - // Code intends to read, which doesn't need to block - if (mine != null) { - mine.value.block() - } else { - readerPool.access(block) - } - } else { - // Code intends to write, for which we're managing locks in code - if (mine != null) { - mine.value.block() - } else { - transactionPool.access(block) - } - } - } - - override fun close() { - transactionPool.close() - readerPool.close() - } -} - -/** - * Helper function to create an in-memory driver. In-memory drivers have a single connection, so - * concurrent access will be block - */ -public fun inMemoryDriver(schema: SqlSchema>): NativeSqliteDriver = - NativeSqliteDriver( - DatabaseConfiguration( - name = null, - inMemory = true, - version = - if (schema.version > - Int.MAX_VALUE - ) { - error("Schema version is larger than Int.MAX_VALUE: ${schema.version}.") - } else { - schema.version.toInt() - }, - create = { connection -> - wrapConnection(connection) { schema.create(it) } - }, - upgrade = { connection, oldVersion, newVersion -> - wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong()) } - }, - ), - ) - -/** - * Sqliter's DatabaseConfiguration takes lambda arguments for it's create and upgrade operations, - * which each take a DatabaseConnection argument. Use wrapConnection to have SqlDelight access this - * passed connection and avoid the pooling that the full SqlDriver instance performs. - * - * Note that queries created during this operation will be cleaned up. If holding onto a cursor from - * a wrap call, it will no longer be viable. - */ -public fun wrapConnection( - connection: DatabaseConnection, - block: (SqlDriver) -> Unit, -) { - val conn = SqliterWrappedConnection(ThreadConnection(connection) {}) - try { - block(conn) - } finally { - conn.close() - } -} - -/** - * SqlDriverConnection that wraps a Sqliter connection. Useful for migration tasks, or if you - * don't want the polling. - */ -internal class SqliterWrappedConnection( - private val threadConnection: ThreadConnection, -) : ConnectionWrapper(), - SqlDriver { - override fun currentTransaction(): Transacter.Transaction? = threadConnection.transaction.value - - override fun newTransaction(): QueryResult = QueryResult.Value(threadConnection.newTransaction()) - - override fun accessConnection( - readOnly: Boolean, - block: ThreadConnection.() -> R, - ): R = threadConnection.block() - - override fun addListener( - vararg queryKeys: String, - listener: Query.Listener, - ) { - // No-op - } - - override fun removeListener( - vararg queryKeys: String, - listener: Query.Listener, - ) { - // No-op - } - - override fun notifyListeners(vararg queryKeys: String) { - // No-op - } - - override fun close() { - threadConnection.cleanUp() - } -} - -/** - * Wraps and manages a "real" database connection. - * - * SQLite statements are specific to connections, and must be finalized explicitly. Cursors are - * backed by a statement resource, so we keep links to open cursors to allow us to close them out - * properly in cases where the user does not. - */ -internal class ThreadConnection( - private val connection: DatabaseConnection, - private val onEndTransaction: (ThreadConnection) -> Unit, -) : Closeable { - internal val transaction = ThreadLocalRef() - private val closed: Boolean - get() = connection.closed - - private val statementCache = mutableMapOf() - - fun useStatement( - identifier: Int?, - sql: String, - ): Statement = - if (identifier != null) { - statementCache.getOrPut(identifier) { - connection.createStatement(sql) - } - } else { - connection.createStatement(sql) - } - - fun clearIfNeeded( - identifier: Int?, - statement: Statement, - ) { - if (identifier == null || closed) { - statement.finalizeStatement() - } - } - - fun newTransaction(): Transacter.Transaction { - val enclosing = transaction.value - - // Create here, in case we bomb... - if (enclosing == null) { - connection.beginTransaction() - } - - val trans = Transaction(enclosing) - transaction.value = trans - - return trans - } - - /** - * This should only be called directly from wrapConnection. Clean resources without actually closing - * the underlying connection. - */ - internal fun cleanUp() { - statementCache.values.forEach { it: Statement -> - it.finalizeStatement() - } - } - - override fun close() { - cleanUp() - connection.close() - } - - private inner class Transaction( - override val enclosingTransaction: Transacter.Transaction?, - ) : Transacter.Transaction() { - override fun endTransaction(successful: Boolean): QueryResult { - transaction.value = enclosingTransaction - - if (enclosingTransaction == null) { - try { - if (successful) { - connection.setTransactionSuccessful() - } - - connection.endTransaction() - } finally { - // Release if we have - onEndTransaction(this@ThreadConnection) - } - } - return QueryResult.Unit - } - } -} - -private inline val DatabaseConfiguration.isEphemeral: Boolean - get() { - return inMemory || (name?.isEmpty() == true && extendedConfig.basePath?.isEmpty() == true) - } diff --git a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/Pool.kt b/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/Pool.kt deleted file mode 100644 index b2741f66..00000000 --- a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/Pool.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.powersync.persistence.driver - -import app.cash.sqldelight.db.Closeable -import co.touchlab.stately.concurrency.AtomicBoolean -import com.powersync.persistence.driver.util.PoolLock -import kotlin.concurrent.AtomicReference - -/** - * A shared pool of connections. Borrowing is blocking when all connections are in use, and the pool has reached its - * designated capacity. - */ -internal class Pool( - internal val capacity: Int, - private val producer: () -> T, -) { - /** - * Hold a list of active connections. If it is null, it means the MultiPool has been closed. - */ - private val entriesRef = AtomicReference?>(listOf()) - private val poolLock = PoolLock() - - /** - * For test purposes only - */ - internal fun entryCount(): Int = - poolLock.withLock { - entriesRef.value?.size ?: 0 - } - - fun borrowEntry(): Borrowed { - val snapshot = entriesRef.value ?: throw ClosedMultiPoolException - - // Fastpath: Borrow the first available entry. - val firstAvailable = snapshot.firstOrNull { it.tryToAcquire() } - - if (firstAvailable != null) { - return firstAvailable.asBorrowed(poolLock) - } - - // Slowpath: Create a new entry if capacity limit has not been reached, or wait for the next available entry. - val nextAvailable = - poolLock.withLock { - // Reload the list since it could've been updated by other threads concurrently. - val entries = entriesRef.value ?: throw ClosedMultiPoolException - - if (entries.count() < capacity) { - // Capacity hasn't been reached — create a new entry to serve this call. - val newEntry = Entry(producer()) - val done = newEntry.tryToAcquire() - check(done) - - entriesRef.value = (entries + listOf(newEntry)) - return@withLock newEntry - } else { - // Capacity is reached — wait for the next available entry. - return@withLock loopForConditionalResult { - // Reload the list, since the thread can be suspended here while the list of entries has been modified. - val innerEntries = entriesRef.value ?: throw ClosedMultiPoolException - innerEntries.firstOrNull { it.tryToAcquire() } - } - } - } - - return nextAvailable.asBorrowed(poolLock) - } - - fun access(action: (T) -> R): R { - val borrowed = borrowEntry() - return try { - action(borrowed.value) - } finally { - borrowed.release() - } - } - - fun close() { - if (!poolLock.close()) { - return - } - - val entries = entriesRef.value - val done = entriesRef.compareAndSet(entries, null) - check(done) - - entries?.forEach { it.value.close() } - } - - inner class Entry( - val value: T, - ) { - val isAvailable = AtomicBoolean(true) - - fun tryToAcquire(): Boolean = isAvailable.compareAndSet(expected = true, new = false) - - fun asBorrowed(poolLock: PoolLock): Borrowed = - object : Borrowed { - override val value: T - get() = this@Entry.value - - override fun release() { - /** - * Mark-as-available should be done before signalling blocked threads via [PoolLock.notifyConditionChanged], - * since the happens-before relationship guarantees the woken thread to see the - * available entry (if not having been taken by other threads during the wake-up lead time). - */ - - val done = isAvailable.compareAndSet(expected = false, new = true) - check(done) - - // While signalling blocked threads does not require locking, doing so avoids a subtle race - // condition in which: - // - // 1. a [loopForConditionalResult] iteration in [borrowEntry] slow path is happening concurrently; - // 2. the iteration fails to see the atomic `isAvailable = true` above; - // 3. we signal availability here but it is a no-op due to no waiting blocker; and finally - // 4. the iteration entered an indefinite blocking wait, not being aware of us having signalled availability here. - // - // By acquiring the pool lock first, signalling cannot happen concurrently with the loop - // iterations in [borrowEntry], thus eliminating the race condition. - poolLock.withLock { - poolLock.notifyConditionChanged() - } - } - } - } -} - -private val ClosedMultiPoolException get() = IllegalStateException("Attempt to access a closed MultiPool.") diff --git a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/SqliterSqlCursor.kt b/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/SqliterSqlCursor.kt deleted file mode 100644 index 89dd41a9..00000000 --- a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/SqliterSqlCursor.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.powersync.persistence.driver - -import app.cash.sqldelight.db.QueryResult -import co.touchlab.sqliter.Cursor -import co.touchlab.sqliter.getBytesOrNull -import co.touchlab.sqliter.getDoubleOrNull -import co.touchlab.sqliter.getLongOrNull -import co.touchlab.sqliter.getStringOrNull - -/** - * Wrapper for cursor calls. Cursors point to real SQLite statements, so we need to be careful with - * them. If dev closes the outer structure, this will get closed as well, which means it could start - * throwing errors if you're trying to access it. - */ -internal class SqliterSqlCursor( - private val cursor: Cursor, -) : ColNamesSqlCursor { - override fun getBytes(index: Int): ByteArray? = cursor.getBytesOrNull(index) - - override fun getDouble(index: Int): Double? = cursor.getDoubleOrNull(index) - - override fun getLong(index: Int): Long? = cursor.getLongOrNull(index) - - override fun getString(index: Int): String? = cursor.getStringOrNull(index) - - override fun getBoolean(index: Int): Boolean? { - return (cursor.getLongOrNull(index) ?: return null) == 1L - } - - override fun columnName(index: Int): String? = cursor.columnName(index) - - override val columnCount: Int = cursor.columnCount - - override fun next(): QueryResult.Value = QueryResult.Value(cursor.next()) -} diff --git a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/SqliterStatement.kt b/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/SqliterStatement.kt deleted file mode 100644 index 624f2fc3..00000000 --- a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/SqliterStatement.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.powersync.persistence.driver - -import app.cash.sqldelight.db.SqlPreparedStatement -import co.touchlab.sqliter.Statement -import co.touchlab.sqliter.bindBlob -import co.touchlab.sqliter.bindDouble -import co.touchlab.sqliter.bindLong -import co.touchlab.sqliter.bindString - -/** - * @param [recycle] A function which recycles any resources this statement is backed by. - */ -internal class SqliterStatement( - private val statement: Statement, -) : SqlPreparedStatement { - override fun bindBytes( - index: Int, - bytes: ByteArray?, - ) { - statement.bindBlob(index + 1, bytes) - } - - override fun bindLong( - index: Int, - long: Long?, - ) { - statement.bindLong(index + 1, long) - } - - override fun bindDouble( - index: Int, - double: Double?, - ) { - statement.bindDouble(index + 1, double) - } - - override fun bindString( - index: Int, - string: String?, - ) { - statement.bindString(index + 1, string) - } - - override fun bindBoolean( - index: Int, - boolean: Boolean?, - ) { - statement.bindLong( - index + 1, - when (boolean) { - null -> null - true -> 1L - false -> 0L - }, - ) - } -} diff --git a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/util/PoolLock.kt b/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/util/PoolLock.kt deleted file mode 100644 index cf8d5e08..00000000 --- a/persistence/src/appleMain/kotlin/com/powersync/persistence/driver/util/PoolLock.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.powersync.persistence.driver.util - -import co.touchlab.stately.concurrency.AtomicBoolean -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.alloc -import kotlinx.cinterop.free -import kotlinx.cinterop.nativeHeap -import kotlinx.cinterop.ptr -import platform.posix.pthread_cond_destroy -import platform.posix.pthread_cond_init -import platform.posix.pthread_cond_signal -import platform.posix.pthread_cond_t -import platform.posix.pthread_cond_wait -import platform.posix.pthread_mutex_destroy -import platform.posix.pthread_mutex_init -import platform.posix.pthread_mutex_lock -import platform.posix.pthread_mutex_t -import platform.posix.pthread_mutex_unlock -import platform.posix.pthread_mutexattr_destroy -import platform.posix.pthread_mutexattr_init -import platform.posix.pthread_mutexattr_settype -import platform.posix.pthread_mutexattr_t - -@OptIn(ExperimentalForeignApi::class) -internal class PoolLock constructor( - reentrant: Boolean = false, -) { - private val isActive = AtomicBoolean(true) - - private val attr = - nativeHeap - .alloc() - .apply { - pthread_mutexattr_init(ptr) - if (reentrant) { - pthread_mutexattr_settype(ptr, platform.posix.PTHREAD_MUTEX_RECURSIVE) - } - } - private val mutex = - nativeHeap - .alloc() - .apply { pthread_mutex_init(ptr, attr.ptr) } - private val cond = - nativeHeap - .alloc() - .apply { pthread_cond_init(ptr, null) } - - fun withLock(action: CriticalSection.() -> R): R { - check(isActive.value) - pthread_mutex_lock(mutex.ptr) - - val result: R - - try { - result = action(CriticalSection()) - } finally { - pthread_mutex_unlock(mutex.ptr) - } - - return result - } - - fun notifyConditionChanged() { - pthread_cond_signal(cond.ptr) - } - - fun close(): Boolean { - if (isActive.compareAndSet(expected = true, new = false)) { - pthread_cond_destroy(cond.ptr) - pthread_mutex_destroy(mutex.ptr) - pthread_mutexattr_destroy(attr.ptr) - nativeHeap.free(cond) - nativeHeap.free(mutex) - nativeHeap.free(attr) - return true - } - - return false - } - - inner class CriticalSection { - fun loopForConditionalResult(block: () -> R?): R { - check(isActive.value) - - var result = block() - - while (result == null) { - pthread_cond_wait(cond.ptr, mutex.ptr) - result = block() - } - - return result - } - } -} diff --git a/persistence/src/commonMain/kotlin/com/persistence/PsInternalDatabase.kt b/persistence/src/commonMain/kotlin/com/persistence/PsInternalDatabase.kt deleted file mode 100644 index 2836dffd..00000000 --- a/persistence/src/commonMain/kotlin/com/persistence/PsInternalDatabase.kt +++ /dev/null @@ -1,4 +0,0 @@ -@file:Suppress("ktlint:standard:no-empty-file") -// Need this for the commonMain source set to be recognized - -package com.persistence diff --git a/persistence/src/commonMain/kotlin/com/powersync/persistence/driver/ColNamesSqlCursor.kt b/persistence/src/commonMain/kotlin/com/powersync/persistence/driver/ColNamesSqlCursor.kt deleted file mode 100644 index 1693bac3..00000000 --- a/persistence/src/commonMain/kotlin/com/powersync/persistence/driver/ColNamesSqlCursor.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.powersync.persistence.driver - -import app.cash.sqldelight.db.SqlCursor - -public interface ColNamesSqlCursor : SqlCursor { - public fun columnName(index: Int): String? - - public val columnCount: Int -} diff --git a/persistence/src/commonMain/sqldelight/com/persistence/Powersync.sq b/persistence/src/commonMain/sqldelight/com/persistence/Powersync.sq deleted file mode 100644 index da30fd6b..00000000 --- a/persistence/src/commonMain/sqldelight/com/persistence/Powersync.sq +++ /dev/null @@ -1,50 +0,0 @@ --- Core queries -powersyncInit: -SELECT powersync_init(); - -sqliteVersion: -SELECT sqlite_version(); - -powerSyncVersion: -SELECT powersync_rs_version(); - -replaceSchema: -SELECT powersync_replace_schema(?); - -powersyncClear: -SELECT powersync_clear(?); - --- CRUD operations -getCrudEntries: -SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT ?; - -getCrudEntryByTxId: -SELECT id, tx_id, data FROM ps_crud WHERE tx_id = ? ORDER BY id ASC; - -deleteEntriesWithIdLessThan: -DELETE FROM ps_crud WHERE id <= ?; - --- Internal tables used by PowerSync. Once (https://github.com/cashapp/sqldelight/pull/4006) is merged, --- we can define interal tables as part of the dialect. -CREATE TABLE IF NOT EXISTS ps_crud (id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT, tx_id INTEGER); - -CREATE TABLE ps_buckets( - name TEXT PRIMARY KEY, - last_applied_op INTEGER NOT NULL DEFAULT 0, - last_op INTEGER NOT NULL DEFAULT 0, - target_op INTEGER NOT NULL DEFAULT 0, - add_checksum INTEGER NOT NULL DEFAULT 0, - pending_delete INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS ps_oplog( - bucket TEXT NOT NULL, - op_id INTEGER NOT NULL, - op INTEGER NOT NULL, - row_type TEXT, - row_id TEXT, - key TEXT, - data TEXT, - hash INTEGER NOT NULL, - superseded INTEGER NOT NULL -); \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 5d32ed81..940aca15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,10 +26,11 @@ include(":core-tests-android") include(":connectors:supabase") include("static-sqlite-driver") -include(":dialect") -include(":persistence") include(":PowerSyncKotlin") +include(":drivers:common") + include(":compose") +include(":integrations:room") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") diff --git a/static-sqlite-driver/build.gradle.kts b/static-sqlite-driver/build.gradle.kts index 7d0e65c1..f4afafd1 100644 --- a/static-sqlite-driver/build.gradle.kts +++ b/static-sqlite-driver/build.gradle.kts @@ -92,7 +92,7 @@ kotlin { nativeTest { dependencies { - implementation(libs.sqliter) + implementation(projects.drivers.common) } } } diff --git a/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt b/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt index 3bf6cf79..9967968e 100644 --- a/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt +++ b/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt @@ -1,25 +1,15 @@ -import co.touchlab.sqliter.DatabaseConfiguration -import co.touchlab.sqliter.createDatabaseManager +import com.powersync.internal.driver.NativeDriver import kotlin.test.Test import kotlin.test.assertEquals class SmokeTest { @Test fun canUseSqlite() { - val manager = - createDatabaseManager( - DatabaseConfiguration( - name = "test", - version = 1, - create = {}, - inMemory = true, - ), - ) - val db = manager.createSingleThreadedConnection() - val stmt = db.createStatement("SELECT sqlite_version();") - val cursor = stmt.query() + val db = NativeDriver().openDatabase(":memory:") + db.prepare("SELECT sqlite_version();").use { stmt -> + assertEquals(true, stmt.step()) + } - assertEquals(true, cursor.next()) db.close() } }