From cbe1691701a9793b2a4938aad8c4e3f7b9200f24 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 22 Jul 2025 23:17:15 +0200 Subject: [PATCH 1/7] New unified driver interfaces --- build.gradle.kts | 2 - compose/build.gradle.kts | 1 - core/build.gradle.kts | 5 +- .../DatabaseDriverFactory.android.kt | 72 +-- .../com/powersync/db/JdbcPreparedStatement.kt | 226 --------- .../com/powersync/db/JdbcSqliteDriver.kt | 149 ------ .../kotlin/com/powersync/db/LoadExtension.kt | 26 + .../kotlin/com/powersync/db/WalProperties.kt | 18 - .../com/powersync/DatabaseDriverFactory.kt | 9 +- .../kotlin/com/powersync/PsSqlDriver.kt | 117 ----- .../kotlin/com/powersync/db/SqlCursor.kt | 39 ++ .../db/internal/ConnectionContext.kt | 77 +++ .../powersync/db/internal/ConnectionPool.kt | 16 +- .../db/internal/InternalDatabaseImpl.kt | 101 ++-- .../powersync/db/internal/InternalSchema.kt | 20 - .../db/internal/PowerSyncTransaction.kt | 69 ++- .../powersync/db/internal/TransactorDriver.kt | 13 - .../com/powersync/db/internal/UpdateFlow.kt | 41 ++ .../powersync/DatabaseDriverFactory.jvm.kt | 52 +- .../powersync/DatabaseDriverFactory.native.kt | 6 + dialect/README.md | 27 -- dialect/build.gradle | 21 - .../com/powersync/sqlite/PowerSyncDialect.kt | 31 -- ...h.sqldelight.dialect.api.SqlDelightDialect | 1 - drivers/common/build.gradle.kts | 62 +++ .../internal/driver/AndroidDriver.kt | 24 + .../powersync/internal/driver/JdbcDriver.kt | 163 +++++++ .../internal/driver/PowerSyncDriver.kt | 24 + .../powersync/internal/driver/NativeDriver.kt | 107 ++++ gradle/libs.versions.toml | 23 +- persistence/.gitignore | 1 - persistence/build.gradle.kts | 90 ---- persistence/gradle.properties | 3 - .../powersync/persistence/driver/Borrowed.kt | 7 - .../persistence/driver/NativeSqlDatabase.kt | 459 ------------------ .../com/powersync/persistence/driver/Pool.kt | 128 ----- .../persistence/driver/SqliterSqlCursor.kt | 35 -- .../persistence/driver/SqliterStatement.kt | 57 --- .../persistence/driver/util/PoolLock.kt | 95 ---- .../com/persistence/PsInternalDatabase.kt | 4 - .../persistence/driver/ColNamesSqlCursor.kt | 9 - .../sqldelight/com/persistence/Powersync.sq | 50 -- settings.gradle.kts | 4 +- static-sqlite-driver/build.gradle.kts | 2 +- .../src/nativeTest/kotlin/SmokeTest.kt | 20 +- 45 files changed, 738 insertions(+), 1768 deletions(-) delete mode 100644 core/src/commonJava/kotlin/com/powersync/db/JdbcPreparedStatement.kt delete mode 100644 core/src/commonJava/kotlin/com/powersync/db/JdbcSqliteDriver.kt create mode 100644 core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt delete mode 100644 core/src/commonJava/kotlin/com/powersync/db/WalProperties.kt delete mode 100644 core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt delete mode 100644 core/src/commonMain/kotlin/com/powersync/db/internal/InternalSchema.kt delete mode 100644 core/src/commonMain/kotlin/com/powersync/db/internal/TransactorDriver.kt create mode 100644 core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt create mode 100644 core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt delete mode 100644 dialect/README.md delete mode 100644 dialect/build.gradle delete mode 100644 dialect/src/main/kotlin/com/powersync/sqlite/PowerSyncDialect.kt delete mode 100644 dialect/src/main/resources/META-INF/services/app.cash.sqldelight.dialect.api.SqlDelightDialect create mode 100644 drivers/common/build.gradle.kts create mode 100644 drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt create mode 100644 drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt create mode 100644 drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt create mode 100644 drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt delete mode 100644 persistence/.gitignore delete mode 100644 persistence/build.gradle.kts delete mode 100644 persistence/gradle.properties delete mode 100644 persistence/src/appleMain/kotlin/com/powersync/persistence/driver/Borrowed.kt delete mode 100644 persistence/src/appleMain/kotlin/com/powersync/persistence/driver/NativeSqlDatabase.kt delete mode 100644 persistence/src/appleMain/kotlin/com/powersync/persistence/driver/Pool.kt delete mode 100644 persistence/src/appleMain/kotlin/com/powersync/persistence/driver/SqliterSqlCursor.kt delete mode 100644 persistence/src/appleMain/kotlin/com/powersync/persistence/driver/SqliterStatement.kt delete mode 100644 persistence/src/appleMain/kotlin/com/powersync/persistence/driver/util/PoolLock.kt delete mode 100644 persistence/src/commonMain/kotlin/com/persistence/PsInternalDatabase.kt delete mode 100644 persistence/src/commonMain/kotlin/com/powersync/persistence/driver/ColNamesSqlCursor.kt delete mode 100644 persistence/src/commonMain/sqldelight/com/persistence/Powersync.sq diff --git a/build.gradle.kts b/build.gradle.kts index 92d75028..e7477f6b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,8 +13,6 @@ 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 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..1e741111 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -201,6 +201,8 @@ kotlin { } dependencies { + api(libs.kermit) + implementation(libs.uuid) implementation(libs.kotlin.stdlib) implementation(libs.ktor.client.core) @@ -213,8 +215,7 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.stately.concurrency) implementation(libs.configuration.annotations) - api(projects.persistence) - api(libs.kermit) + implementation(projects.drivers.common) } } diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 8eba77b2..62f8e9e5 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -1,79 +1,37 @@ 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.db.setSchemaVersion +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.setSchemaVersion() + 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/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..d8e5c6fb --- /dev/null +++ b/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt @@ -0,0 +1,26 @@ +package com.powersync.db + +import androidx.sqlite.execSQL +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) +} + +/** + * Sets the user version pragma to `1` to continue the behavior of older versions of the PowerSync + * SDK. + */ +internal fun JdbcConnection.setSchemaVersion() { + execSQL("pragma user_version = 1") +} 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/SqlCursor.kt b/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt index bca14a55..72af4630 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt @@ -1,5 +1,6 @@ package com.powersync.db +import androidx.sqlite.SQLiteStatement import co.touchlab.skie.configuration.annotations.FunctionInterop import com.powersync.PowerSyncException @@ -29,6 +30,44 @@ 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? { + return getLong(index) != 0L + } + + override fun getBytes(index: Int): ByteArray? { + return stmt.getBlob(index) + } + + override fun getDouble(index: Int): Double? { + return stmt.getDouble(index) + } + + override fun getLong(index: Int): Long? { + return stmt.getLong(index) + } + + override fun getString(index: Int): String? { + return stmt.getText(index) + } + + override fun columnName(index: Int): String? { + return stmt.getColumnName(index) + } + + override val columnCount: Int + get() = stmt.getColumnCount() + + override val columnNames: Map by lazy { + buildMap { + stmt.getColumnNames().forEachIndexed { index, name -> + put(name, 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..c15ca2d1 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,77 @@ public interface ConnectionContext { mapper: (SqlCursor) -> RowType, ): RowType } + +internal class ConnectionContextImplementation(val connection: SQLiteConnection): ConnectionContext { + override fun execute( + sql: String, + parameters: List? + ): Long { + TODO("Not yet implemented") + } + + override fun getOptional( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType + ): RowType? { + return getSequence(sql, parameters, mapper).firstOrNull() + } + + override fun getAll( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType + ): List { + return getSequence(sql, parameters, mapper).toList() + } + + override fun get( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType + ): RowType { + return getOptional(sql, parameters, mapper) ?: throw PowerSyncException("get() called with query that returned no rows", null) + } + + private fun getSequence( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType + ): Sequence = sequence { + val stmt = prepareStmt(sql, parameters) + val cursor = StatementBasedCursor(stmt) + + while (stmt.step()) { + yield(mapper(cursor)) + } + } + + private fun prepareStmt(sql: String, parameters: List?): SQLiteStatement { + return connection.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..4498519f 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 withConnection(action: suspend (connection: SQLiteConnection) -> R): R { val (connection, done) = try { available.receive() @@ -56,8 +56,8 @@ internal class ConnectionPool( } } - 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..82cefe07 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -1,6 +1,8 @@ package com.powersync.db.internal -import app.cash.sqldelight.db.SqlPreparedStatement +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import androidx.sqlite.execSQL import com.powersync.DatabaseDriverFactory import com.powersync.PowerSyncException import com.powersync.db.SqlCursor @@ -31,28 +33,47 @@ internal class InternalDatabaseImpl( 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() + + private val writeConnection = factory.openDatabase( + dbFilename = dbFilename, + dbDirectory = dbDirectory, + readOnly = false, + listener = updates, + ) private val readPool = ConnectionPool(factory = { - factory.createDriver( - scope = scope, + factory.openDatabase( dbFilename = dbFilename, dbDirectory = dbDirectory, readOnly = true, + + listener = null, ) }, 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 = readOnly, + // 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}") + + return connection + } + override suspend fun execute( sql: String, parameters: List?, @@ -75,7 +96,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')") {} + } } } } @@ -177,7 +201,7 @@ internal class InternalDatabaseImpl( /** * Creates a read lock while providing an internal transactor for transactions */ - private suspend fun internalReadLock(callback: (TransactorDriver) -> R): R = + private suspend fun internalReadLock(callback: (SQLiteConnection) -> R): R = withContext(dbContext) { runWrapped { readPool.withConnection { @@ -190,23 +214,19 @@ internal class InternalDatabaseImpl( 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 = + private suspend fun internalWriteLock(callback: (SQLiteConnection) -> R): R = withContext(dbContext) { writeLockMutex.withLock { runWrapped { @@ -216,32 +236,28 @@ internal class InternalDatabaseImpl( }.also { // Trigger watched queries // Fire updates inside the write lock - writeConnection.driver.fireTableUpdates() + updates.fireTableUpdates() } } } 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. @@ -292,7 +308,7 @@ internal class InternalDatabaseImpl( override suspend fun close() { runWrapped { - writeConnection.driver.close() + writeConnection.close() readPool.close() } } @@ -317,26 +333,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..2ade288e 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,73 @@ 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 connection: SQLiteConnection, ) : PowerSyncTransaction, - ConnectionContext by context + ConnectionContext { + private val delegate = ConnectionContextImplementation(connection) + + private fun checkInTransaction() { + if (!connection.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") + return try { + val result = cb(PowerSyncTransactionImpl(this)) + + check(inTransaction()) + execSQL("COMMIT") + result + } catch (e: Throwable) { + if (inTransaction()) { + execSQL("ROLLBACK") + } + + throw e + } +} 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..d4b3cff8 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt @@ -0,0 +1,41 @@ +package com.powersync.db.internal + +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: 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() { + 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() + tableUpdatesFlow.emit(updates) + } +} diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 39864b54..cb7d94da 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -1,22 +1,20 @@ 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.db.setSchemaVersion +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 +22,14 @@ 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.setSchemaVersion() + 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/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt b/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt new file mode 100644 index 00000000..ec8c33bd --- /dev/null +++ b/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt @@ -0,0 +1,6 @@ +package com.powersync + +import com.powersync.internal.driver.NativeDriver +import com.powersync.internal.driver.PowerSyncDriver + +public actual val RawDatabaseFactory: PowerSyncDriver = NativeDriver() 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/common/build.gradle.kts b/drivers/common/build.gradle.kts new file mode 100644 index 00000000..f714c4b4 --- /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.compose" + 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..7abb9655 --- /dev/null +++ b/drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt @@ -0,0 +1,24 @@ +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..5873669a --- /dev/null +++ b/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt @@ -0,0 +1,163 @@ +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 { + return !connection.autoCommit + } + + override fun prepare(sql: String): SQLiteStatement { + return 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 fun requireCursor(): JDBC4ResultSet { + return 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 { + return requireCursor().getBytes(index) + } + + override fun getDouble(index: Int): Double { + return requireCursor().getDouble(index) + } + + override fun getLong(index: Int): Long { + return requireCursor().getLong(index) + } + + override fun getText(index: Int): String { + return requireCursor().getString(index ) + } + + override fun isNull(index: Int): Boolean { + return getColumnType(index) == SQLITE_DATA_NULL + } + + override fun getColumnCount(): Int { + return currentCursor!!.metaData.columnCount + } + + override fun getColumnName(index: Int): String { + return stmt.metaData.getColumnName(index) + } + + override fun getColumnType(index: Int): Int { + return stmt.pointer.safeRunInt { db, ptr -> db.column_type(ptr, index ) } + } + + override fun step(): Boolean { + if (currentCursor == null) { + 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() + 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..0bb0f34c --- /dev/null +++ b/drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt @@ -0,0 +1,24 @@ +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..30348a47 --- /dev/null +++ b/drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt @@ -0,0 +1,107 @@ +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 { + 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) + } + + ListenerConnection(dbPointer.value!!, listener) + } + } +} + +private class ListenerConnection( + 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 { + return inner.inTransaction() + } + + override fun prepare(sql: String): SQLiteStatement { + return 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..bf9e4f95 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,6 @@ 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" @@ -24,14 +23,13 @@ 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" # plugins android-gradle-plugin = "8.10.1" @@ -90,22 +88,13 @@ 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" } # Sample - Android @@ -127,8 +116,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 +123,3 @@ 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" -] 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..51df18b0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,10 +26,10 @@ include(":core-tests-android") include(":connectors:supabase") include("static-sqlite-driver") -include(":dialect") -include(":persistence") include(":PowerSyncKotlin") +include(":drivers:common") + include(":compose") 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() } } From 9884eb099681117179fd8742e752f71b8a98a331 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 24 Jul 2025 09:21:39 +0200 Subject: [PATCH 2/7] Fix leaking statements --- core/build.gradle.kts | 1 + .../DatabaseDriverFactory.android.kt | 2 - .../powersync/DatabaseDriverFactory.apple.kt | 205 ++++-------------- .../kotlin/com/powersync/DeferredDriver.kt | 27 --- .../com/powersync/sync/SyncIntegrationTest.kt | 5 +- .../kotlin/com/powersync/db/LoadExtension.kt | 9 - .../com/powersync/db/PowerSyncDatabaseImpl.kt | 2 + .../kotlin/com/powersync/db/SqlCursor.kt | 35 ++- .../db/internal/ConnectionContext.kt | 39 ++-- .../db/internal/InternalDatabaseImpl.kt | 38 ++-- .../db/internal/PowerSyncTransaction.kt | 6 +- .../powersync/db/internal/SqlCursorWrapper.kt | 48 ---- .../com/powersync/db/internal/UpdateFlow.kt | 8 +- .../powersync/DatabaseDriverFactory.ios.kt | 4 +- .../powersync/DatabaseDriverFactory.jvm.kt | 2 - .../powersync/DatabaseDriverFactory.macos.kt | 4 +- .../powersync/DatabaseDriverFactory.native.kt | 6 - .../DatabaseDriverFactory.watchos.kt | 4 +- .../powersync/internal/driver/JdbcDriver.kt | 34 ++- .../powersync/internal/driver/NativeDriver.kt | 14 +- 20 files changed, 177 insertions(+), 316 deletions(-) delete mode 100644 core/src/appleMain/kotlin/com/powersync/DeferredDriver.kt delete mode 100644 core/src/commonMain/kotlin/com/powersync/db/internal/SqlCursorWrapper.kt delete mode 100644 core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 1e741111..b3fddce8 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -234,6 +234,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 62f8e9e5..3987708b 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -3,7 +3,6 @@ package com.powersync import android.content.Context import androidx.sqlite.SQLiteConnection import com.powersync.db.loadExtensions -import com.powersync.db.setSchemaVersion import com.powersync.internal.driver.AndroidDriver import com.powersync.internal.driver.ConnectionListener import com.powersync.internal.driver.JdbcConnection @@ -27,7 +26,6 @@ public actual class DatabaseDriverFactory( val driver = AndroidDriver(context) val connection = driver.openDatabase(dbPath, readOnly, listener) as JdbcConnection - connection.setSchemaVersion() connection.loadExtensions( "libpowersync.so" to "sqlite3_powersync_init", ) diff --git a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt index 943c3a12..fa031997 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,34 @@ 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 +100,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 +112,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/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index 84743ac0..ce138fd3 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -597,7 +597,10 @@ 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/LoadExtension.kt b/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt index d8e5c6fb..42febe15 100644 --- a/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt +++ b/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt @@ -1,6 +1,5 @@ package com.powersync.db -import androidx.sqlite.execSQL import com.powersync.internal.driver.JdbcConnection internal fun JdbcConnection.loadExtensions(vararg extensions: Pair) { @@ -16,11 +15,3 @@ internal fun JdbcConnection.loadExtensions(vararg extensions: Pair SqlCursor.getColumnValue( internal class StatementBasedCursor(private val stmt: SQLiteStatement): SqlCursor { override fun getBoolean(index: Int): Boolean? { - return getLong(index) != 0L + return getNullable(index) { index -> stmt.getLong(index) != 0L } } override fun getBytes(index: Int): ByteArray? { - return stmt.getBlob(index) + return getNullable(index, SQLiteStatement::getBlob) } override fun getDouble(index: Int): Double? { - return stmt.getDouble(index) + return getNullable(index, SQLiteStatement::getDouble) } override fun getLong(index: Int): Long? { - return stmt.getLong(index) + return getNullable(index, SQLiteStatement::getLong) } override fun getString(index: Int): String? { - return stmt.getText(index) + return getNullable(index, SQLiteStatement::getText) + } + + private inline fun getNullable(index: Int, read: SQLiteStatement.(Int) -> T): T? { + return if (stmt.isNull(index)) { + null + } else { + stmt.read(index) + } } override fun columnName(index: Int): String? { @@ -60,8 +69,20 @@ internal class StatementBasedCursor(private val stmt: SQLiteStatement): SqlCurso override val columnNames: Map by lazy { buildMap { - stmt.getColumnNames().forEachIndexed { index, name -> - put(name, index) + 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) } } } 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 c15ca2d1..8a38ad03 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt @@ -40,7 +40,14 @@ internal class ConnectionContextImplementation(val connection: SQLiteConnection) sql: String, parameters: List? ): Long { - TODO("Not yet implemented") + withStatement(sql, parameters) { + while (it.step()) { + // Iterate through the statement + } + + // TODO: What is this even supposed to return + return 0L + } } override fun getOptional( @@ -48,7 +55,13 @@ internal class ConnectionContextImplementation(val connection: SQLiteConnection) parameters: List?, mapper: (SqlCursor) -> RowType ): RowType? { - return getSequence(sql, parameters, mapper).firstOrNull() + return withStatement(sql, parameters) { stmt -> + if (stmt.step()) { + mapper(StatementBasedCursor(stmt)) + } else { + null + } + } } override fun getAll( @@ -56,7 +69,14 @@ internal class ConnectionContextImplementation(val connection: SQLiteConnection) parameters: List?, mapper: (SqlCursor) -> RowType ): List { - return getSequence(sql, parameters, mapper).toList() + return withStatement(sql, parameters) { stmt -> + buildList { + val cursor = StatementBasedCursor(stmt) + while (stmt.step()) { + add(mapper(cursor)) + } + } + } } override fun get( @@ -67,17 +87,8 @@ internal class ConnectionContextImplementation(val connection: SQLiteConnection) return getOptional(sql, parameters, mapper) ?: throw PowerSyncException("get() called with query that returned no rows", null) } - private fun getSequence( - sql: String, - parameters: List?, - mapper: (SqlCursor) -> RowType - ): Sequence = sequence { - val stmt = prepareStmt(sql, parameters) - val cursor = StatementBasedCursor(stmt) - - while (stmt.step()) { - yield(mapper(cursor)) - } + private inline fun withStatement(sql: String, parameters: List?, block: (SQLiteStatement) -> T): T { + return prepareStmt(sql, parameters).use(block) } private fun prepareStmt(sql: String, parameters: List?): SQLiteStatement { 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 82cefe07..943afbee 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -1,8 +1,8 @@ package com.powersync.db.internal import androidx.sqlite.SQLiteConnection -import androidx.sqlite.SQLiteStatement import androidx.sqlite.execSQL +import co.touchlab.kermit.Logger import com.powersync.DatabaseDriverFactory import com.powersync.PowerSyncException import com.powersync.db.SqlCursor @@ -29,28 +29,18 @@ 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 updates = UpdateFlow() + private val updates = UpdateFlow(logger) - private val writeConnection = factory.openDatabase( - dbFilename = dbFilename, - dbDirectory = dbDirectory, - readOnly = false, - listener = updates, - ) + private val writeConnection = newConnection(false) private val readPool = ConnectionPool(factory = { - factory.openDatabase( - dbFilename = dbFilename, - dbDirectory = dbDirectory, - readOnly = true, - - listener = null, - ) + newConnection(true) }, scope = scope) // Could be scope.coroutineContext, but the default is GlobalScope, which seems like a bad idea. To discuss. @@ -60,7 +50,7 @@ internal class InternalDatabaseImpl( val connection = factory.openDatabase( dbFilename = dbFilename, dbDirectory = dbDirectory, - readOnly = readOnly, + 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, @@ -71,6 +61,22 @@ internal class InternalDatabaseImpl( 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 } 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 2ade288e..b1f76df6 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt @@ -57,14 +57,16 @@ internal class PowerSyncTransactionImpl( 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 (inTransaction()) { + if (!didComplete && inTransaction()) { execSQL("ROLLBACK") } 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/UpdateFlow.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt index d4b3cff8..37bb159d 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt @@ -1,12 +1,13 @@ 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: ConnectionListener { +internal class UpdateFlow(private val logger: Logger): ConnectionListener { // MutableSharedFlow to emit batched table updates private val tableUpdatesFlow = MutableSharedFlow>(replay = 0) @@ -16,6 +17,7 @@ internal class UpdateFlow: ConnectionListener { override fun onCommit() {} override fun onRollback() { + logger.v { "onRollback, clearing pending updates" } pendingUpdates.clear() } @@ -36,6 +38,10 @@ internal class UpdateFlow: ConnectionListener { 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 cb7d94da..252e2814 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -2,7 +2,6 @@ package com.powersync import androidx.sqlite.SQLiteConnection import com.powersync.db.loadExtensions -import com.powersync.db.setSchemaVersion import com.powersync.internal.driver.ConnectionListener import com.powersync.internal.driver.JdbcConnection import com.powersync.internal.driver.JdbcDriver @@ -24,7 +23,6 @@ public actual class DatabaseDriverFactory { val driver = JdbcDriver() val connection = driver.openDatabase(dbPath, readOnly, listener) as JdbcConnection - connection.setSchemaVersion() connection.loadExtensions( powersyncExtension to "sqlite3_powersync_init", ) 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/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt b/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt deleted file mode 100644 index ec8c33bd..00000000 --- a/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.powersync - -import com.powersync.internal.driver.NativeDriver -import com.powersync.internal.driver.PowerSyncDriver - -public actual val RawDatabaseFactory: PowerSyncDriver = NativeDriver() 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/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt b/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt index 5873669a..3348e4a9 100644 --- a/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt +++ b/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt @@ -62,9 +62,12 @@ public open class JdbcDriver: PowerSyncDriver { } } -public class JdbcConnection(public val connection: org.sqlite.SQLiteConnection): SQLiteConnection { +public class JdbcConnection( + public val connection: org.sqlite.SQLiteConnection, +): SQLiteConnection { override fun inTransaction(): Boolean { - return !connection.autoCommit + // TODO: Unsupported with sqlite-jdbc? + return true } override fun prepare(sql: String): SQLiteStatement { @@ -81,6 +84,12 @@ private class PowerSyncStatement( ): 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 { return requireNotNull(currentCursor) { "Illegal call which requires cursor, step() hasn't been called" @@ -108,19 +117,19 @@ private class PowerSyncStatement( } override fun getBlob(index: Int): ByteArray { - return requireCursor().getBytes(index) + return requireCursor().getBytes(index + 1) } override fun getDouble(index: Int): Double { - return requireCursor().getDouble(index) + return requireCursor().getDouble(index + 1) } override fun getLong(index: Int): Long { - return requireCursor().getLong(index) + return requireCursor().getLong(index + 1) } override fun getText(index: Int): String { - return requireCursor().getString(index ) + return requireCursor().getString(index + 1) } override fun isNull(index: Int): Boolean { @@ -128,11 +137,11 @@ private class PowerSyncStatement( } override fun getColumnCount(): Int { - return currentCursor!!.metaData.columnCount + return _columnCount } override fun getColumnName(index: Int): String { - return stmt.metaData.getColumnName(index) + return stmt.metaData.getColumnName(index + 1) } override fun getColumnType(index: Int): Int { @@ -141,7 +150,13 @@ private class PowerSyncStatement( override fun step(): Boolean { if (currentCursor == null) { - currentCursor = stmt.executeQuery() as JDBC4ResultSet + 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() @@ -158,6 +173,7 @@ private class PowerSyncStatement( override fun close() { currentCursor?.close() + currentCursor = null stmt.close() } } 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 index 30348a47..9cf78c9a 100644 --- a/drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt +++ b/drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt @@ -29,7 +29,13 @@ public class NativeDriver : PowerSyncDriver { path: String, readOnly: Boolean, listener: ConnectionListener?, - ): SQLiteConnection { + ): SQLiteConnection = openNativeDatabase(path, readOnly, listener) + + public fun openNativeDatabase( + path: String, + readOnly: Boolean, + listener: ConnectionListener?, + ): NativeConnection { val flags = if (readOnly) { SQLITE_OPEN_READONLY } else { @@ -45,13 +51,13 @@ public class NativeDriver : PowerSyncDriver { throwSQLiteException(resultCode, null) } - ListenerConnection(dbPointer.value!!, listener) + NativeConnection(dbPointer.value!!, listener) } } } -private class ListenerConnection( - sqlite: CPointer, +public class NativeConnection( + public val sqlite: CPointer, listener: ConnectionListener? ): SQLiteConnection { private val inner: NativeSQLiteConnection = NativeSQLiteConnection(sqlite) From 5a2b13146d4f4dfb1d649111b50b26a5d4aa11be Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 25 Jul 2025 09:52:02 +0200 Subject: [PATCH 3/7] Add raw connection API --- core/build.gradle.kts | 2 + .../DatabaseDriverFactory.android.kt | 2 +- .../powersync/DatabaseDriverFactory.apple.kt | 11 ++-- .../kotlin/com/powersync/DatabaseTest.kt | 37 ++++++++++++ .../com/powersync/sync/SyncIntegrationTest.kt | 10 ++-- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 2 +- .../kotlin/com/powersync/db/SqlCursor.kt | 58 ++++++++----------- .../db/internal/ConnectionContext.kt | 46 ++++++++------- .../db/internal/InternalDatabaseImpl.kt | 36 +++++++----- .../db/internal/PowerSyncTransaction.kt | 16 ++--- .../db/internal/RawConnectionLease.kt | 30 ++++++++++ .../com/powersync/db/internal/UpdateFlow.kt | 6 +- .../powersync/DatabaseDriverFactory.jvm.kt | 2 +- 13 files changed, 168 insertions(+), 90 deletions(-) create mode 100644 core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b3fddce8..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") } } @@ -202,6 +203,7 @@ kotlin { dependencies { api(libs.kermit) + api(libs.androidx.sqlite) implementation(libs.uuid) implementation(libs.kotlin.stdlib) diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 3987708b..3b593785 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -15,7 +15,7 @@ public actual class DatabaseDriverFactory( dbFilename: String, dbDirectory: String?, readOnly: Boolean, - listener: ConnectionListener? + listener: ConnectionListener?, ): SQLiteConnection { val dbPath = if (dbDirectory != null) { diff --git a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt index fa031997..d5ab6c71 100644 --- a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt +++ b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt @@ -33,7 +33,7 @@ public actual class DatabaseDriverFactory { dbFilename: String, dbDirectory: String?, readOnly: Boolean, - listener: ConnectionListener? + listener: ConnectionListener?, ): SQLiteConnection { val directory = dbDirectory ?: defaultDatabaseDirectory() val path = Path(directory, dbFilename).toString() @@ -61,15 +61,16 @@ public actual class DatabaseDriverFactory { @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 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 + if (!fileManager.fileExistsAtPath(databaseDirectory)) { + fileManager.createDirectoryAtPath(databaseDirectory, true, null, null) + }; // Create folder return databaseDirectory } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 3e5f5f19..64ef065e 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -1,5 +1,7 @@ package com.powersync +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi import com.powersync.db.ActiveDatabaseGroup @@ -13,6 +15,7 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.throwable.shouldHaveMessage import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -459,4 +462,38 @@ class DatabaseTest { database.getCrudBatch() shouldBe null } + + @Test + fun testRawConnection() = + databaseTest { + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf("a", "a@example.org"), + ) + var capturedConnection: SQLiteConnection? = null + + database.readLock { + it.rawConnection.prepare("SELECT * FROM users").use { stmt -> + stmt.step() shouldBe true + stmt.getText(1) shouldBe "a" + stmt.getText(2) shouldBe "a@example.org" + } + + capturedConnection = it.rawConnection + } + + // When we exit readLock, the connection should no longer be usable + shouldThrow { capturedConnection!!.execSQL("DELETE FROM users") } shouldHaveMessage + "Connection lease already closed" + + capturedConnection = null + database.writeLock { + it.rawConnection.execSQL("DELETE FROM users") + capturedConnection = it.rawConnection + } + + // Same thing for writes + shouldThrow { capturedConnection!!.prepare("SELECT * FROM users") } shouldHaveMessage + "Connection lease already closed" + } } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index ce138fd3..8e314629 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -597,10 +597,12 @@ abstract class BaseSyncIntegrationTest( val turbine = database.currentStatus.asFlow().testIn(scope) turbine.waitFor { it.connected } - val query = database.watch("SELECT name FROM users") { - println("interpreting results: ${it.getString(0)}") - 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/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 50f5ae08..f1896439 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -1,5 +1,6 @@ package com.powersync.db +import androidx.sqlite.SQLiteConnection import co.touchlab.kermit.Logger import com.powersync.DatabaseDriverFactory import com.powersync.PowerSyncDatabase @@ -46,7 +47,6 @@ import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant -import kotlin.math.log import kotlin.time.Duration.Companion.milliseconds /** diff --git a/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt b/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt index 63e5e45f..64fed97a 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt @@ -31,38 +31,30 @@ 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? { - return getNullable(index) { index -> stmt.getLong(index) != 0L } - } +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? { - return getNullable(index, SQLiteStatement::getBlob) - } + override fun getBytes(index: Int): ByteArray? = getNullable(index, SQLiteStatement::getBlob) - override fun getDouble(index: Int): Double? { - return getNullable(index, SQLiteStatement::getDouble) - } + override fun getDouble(index: Int): Double? = getNullable(index, SQLiteStatement::getDouble) - override fun getLong(index: Int): Long? { - return getNullable(index, SQLiteStatement::getLong) - } + override fun getLong(index: Int): Long? = getNullable(index, SQLiteStatement::getLong) - override fun getString(index: Int): String? { - return getNullable(index, SQLiteStatement::getText) - } + override fun getString(index: Int): String? = getNullable(index, SQLiteStatement::getText) - private inline fun getNullable(index: Int, read: SQLiteStatement.(Int) -> T): T? { - return if (stmt.isNull(index)) { + 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? { - return stmt.getColumnName(index) - } + override fun columnName(index: Int): String? = stmt.getColumnName(index) override val columnCount: Int get() = stmt.getColumnCount() @@ -70,23 +62,23 @@ internal class StatementBasedCursor(private val stmt: SQLiteStatement): SqlCurso 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 + 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 } - finalKey - } else { - key - } put(finalKey, index) } } } - } private inline fun SqlCursor.getColumnValueOptional( 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 8a38ad03..2ab5c2cd 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt @@ -5,8 +5,12 @@ import androidx.sqlite.SQLiteStatement import com.powersync.PowerSyncException import com.powersync.db.SqlCursor import com.powersync.db.StatementBasedCursor +import kotlin.native.HiddenFromObjC public interface ConnectionContext { + @HiddenFromObjC + public val rawConnection: SQLiteConnection + @Throws(PowerSyncException::class) public fun execute( sql: String, @@ -35,10 +39,12 @@ public interface ConnectionContext { ): RowType } -internal class ConnectionContextImplementation(val connection: SQLiteConnection): ConnectionContext { +internal class ConnectionContextImplementation( + override val rawConnection: SQLiteConnection, +) : ConnectionContext { override fun execute( sql: String, - parameters: List? + parameters: List?, ): Long { withStatement(sql, parameters) { while (it.step()) { @@ -53,23 +59,22 @@ internal class ConnectionContextImplementation(val connection: SQLiteConnection) override fun getOptional( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType - ): RowType? { - return withStatement(sql, parameters) { stmt -> + 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 { - return withStatement(sql, parameters) { stmt -> + mapper: (SqlCursor) -> RowType, + ): List = + withStatement(sql, parameters) { stmt -> buildList { val cursor = StatementBasedCursor(stmt) while (stmt.step()) { @@ -77,22 +82,24 @@ internal class ConnectionContextImplementation(val connection: SQLiteConnection) } } } - } override fun get( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType - ): RowType { - return getOptional(sql, parameters, mapper) ?: throw PowerSyncException("get() called with query that returned no rows", null) - } + 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 { - return prepareStmt(sql, parameters).use(block) - } + 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 { - return connection.prepare(sql).apply { + private fun prepareStmt( + sql: String, + parameters: List?, + ): SQLiteStatement = + rawConnection.prepare(sql).apply { try { parameters?.forEachIndexed { i, parameter -> // SQLite parameters are 1-indexed @@ -117,5 +124,4 @@ internal class ConnectionContextImplementation(val connection: SQLiteConnection) throw e } } - } } 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 943afbee..52257c56 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -47,14 +47,15 @@ internal class InternalDatabaseImpl( 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, - ) + 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}") @@ -68,10 +69,11 @@ internal class InternalDatabaseImpl( // 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) - } + 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") } @@ -212,7 +214,8 @@ internal class InternalDatabaseImpl( runWrapped { readPool.withConnection { catchSwiftExceptions { - callback(it) + val lease = RawConnectionLease(it) + callback(lease).also { lease.completed = true } } } } @@ -235,14 +238,17 @@ internal class InternalDatabaseImpl( private suspend fun internalWriteLock(callback: (SQLiteConnection) -> R): R = withContext(dbContext) { writeLockMutex.withLock { + val lease = RawConnectionLease(writeConnection) + runWrapped { catchSwiftExceptions { - callback(writeConnection) + callback(lease) } }.also { // Trigger watched queries // Fire updates inside the write lock updates.fireTableUpdates() + lease.completed = true } } } @@ -267,7 +273,7 @@ internal class InternalDatabaseImpl( // 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) { 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 b1f76df6..dcd6e715 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt @@ -8,20 +8,20 @@ import com.powersync.db.SqlCursor public interface PowerSyncTransaction : ConnectionContext internal class PowerSyncTransactionImpl( - private val connection: SQLiteConnection, + override val rawConnection: SQLiteConnection ) : PowerSyncTransaction, ConnectionContext { - private val delegate = ConnectionContextImplementation(connection) + private val delegate = ConnectionContextImplementation(rawConnection) private fun checkInTransaction() { - if (!connection.inTransaction()) { + 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? + parameters: List?, ): Long { checkInTransaction() return delegate.execute(sql, parameters) @@ -30,7 +30,7 @@ internal class PowerSyncTransactionImpl( override fun getOptional( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType + mapper: (SqlCursor) -> RowType, ): RowType? { checkInTransaction() return delegate.getOptional(sql, parameters, mapper) @@ -39,7 +39,7 @@ internal class PowerSyncTransactionImpl( override fun getAll( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType + mapper: (SqlCursor) -> RowType, ): List { checkInTransaction() return delegate.getAll(sql, parameters, mapper) @@ -48,7 +48,7 @@ internal class PowerSyncTransactionImpl( override fun get( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType + mapper: (SqlCursor) -> RowType, ): RowType { checkInTransaction() return delegate.get(sql, parameters, mapper) @@ -61,7 +61,7 @@ internal inline fun SQLiteConnection.runTransaction(cb: (PowerSyncTransactio return try { val result = cb(PowerSyncTransactionImpl(this)) didComplete = true - + check(inTransaction()) execSQL("COMMIT") result 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..f1eb4c20 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt @@ -0,0 +1,30 @@ +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, + var completed: Boolean = false, +) : SQLiteConnection { + private fun checkNotCompleted() { + check(!completed) { "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. + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt index 37bb159d..c7adab10 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt @@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -internal class UpdateFlow(private val logger: Logger): ConnectionListener { +internal class UpdateFlow( + private val logger: Logger, +) : ConnectionListener { // MutableSharedFlow to emit batched table updates private val tableUpdatesFlow = MutableSharedFlow>(replay = 0) @@ -25,7 +27,7 @@ internal class UpdateFlow(private val logger: Logger): ConnectionListener { kind: Int, database: String, table: String, - rowid: Long + rowid: Long, ) { pendingUpdates.add(table) } diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 252e2814..7a3efba2 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -12,7 +12,7 @@ public actual class DatabaseDriverFactory { dbFilename: String, dbDirectory: String?, readOnly: Boolean, - listener: ConnectionListener? + listener: ConnectionListener?, ): SQLiteConnection { val dbPath = if (dbDirectory != null) { From 62b5b72a4c74b9b0876e0fd7966d93edf110e3b5 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 25 Jul 2025 10:24:54 +0200 Subject: [PATCH 4/7] Add changelog entry --- CHANGELOG.md | 5 ++++- .../kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt | 1 - .../kotlin/com/powersync/db/internal/PowerSyncTransaction.kt | 2 +- drivers/README.md | 3 +++ gradle/libs.versions.toml | 2 -- 5 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 drivers/README.md 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/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index f1896439..1ae56787 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -1,6 +1,5 @@ package com.powersync.db -import androidx.sqlite.SQLiteConnection import co.touchlab.kermit.Logger import com.powersync.DatabaseDriverFactory import com.powersync.PowerSyncDatabase 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 dcd6e715..65d6ea04 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt @@ -8,7 +8,7 @@ import com.powersync.db.SqlCursor public interface PowerSyncTransaction : ConnectionContext internal class PowerSyncTransactionImpl( - override val rawConnection: SQLiteConnection + override val rawConnection: SQLiteConnection, ) : PowerSyncTransaction, ConnectionContext { private val delegate = ConnectionContextImplementation(rawConnection) 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf9e4f95..de6c06c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,6 @@ 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" @@ -36,7 +35,6 @@ 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" From 9b3b4211e7e1866531fd0dcc11cc09083eac2325 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 25 Jul 2025 11:40:10 +0200 Subject: [PATCH 5/7] Lease API that works better with Room --- .../kotlin/com/powersync/DatabaseTest.kt | 60 +++++---- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 8 ++ .../kotlin/com/powersync/db/Queries.kt | 20 +++ .../db/internal/ConnectionContext.kt | 6 +- .../powersync/db/internal/ConnectionPool.kt | 8 +- .../db/internal/InternalDatabaseImpl.kt | 31 +++-- .../db/internal/PowerSyncTransaction.kt | 2 +- .../db/internal/RawConnectionLease.kt | 10 +- drivers/common/build.gradle.kts | 2 +- .../internal/driver/AndroidDriver.kt | 4 +- .../powersync/internal/driver/JdbcDriver.kt | 120 +++++++++--------- .../internal/driver/PowerSyncDriver.kt | 9 +- .../powersync/internal/driver/NativeDriver.kt | 48 ++++--- 13 files changed, 196 insertions(+), 132 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 64ef065e..ce524e16 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -1,10 +1,9 @@ package com.powersync -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.execSQL 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 @@ -15,16 +14,17 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain -import io.kotest.matchers.throwable.shouldHaveMessage 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 { @@ -464,36 +464,52 @@ class DatabaseTest { } @Test - fun testRawConnection() = + @OptIn(ExperimentalPowerSyncAPI::class) + fun testLeaseReadOnly() = databaseTest { database.execute( "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", listOf("a", "a@example.org"), ) - var capturedConnection: SQLiteConnection? = null - database.readLock { - it.rawConnection.prepare("SELECT * FROM users").use { stmt -> - stmt.step() shouldBe true - stmt.getText(1) shouldBe "a" - stmt.getText(2) shouldBe "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() + } - capturedConnection = it.rawConnection + @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 } - // When we exit readLock, the connection should no longer be usable - shouldThrow { capturedConnection!!.execSQL("DELETE FROM users") } shouldHaveMessage - "Connection lease already closed" + database.getAll("SELECT * FROM users") { it.getString("name") } shouldHaveSize 2 - capturedConnection = null - database.writeLock { - it.rawConnection.execSQL("DELETE FROM users") - capturedConnection = it.rawConnection + // 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) } - // Same thing for writes - shouldThrow { capturedConnection!!.prepare("SELECT * FROM users") } shouldHaveMessage - "Connection lease already closed" + delay(100.milliseconds) + hadOtherWrite.isCompleted shouldBe false + raw.close() + hadOtherWrite.await() } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 1ae56787..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 @@ -316,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/internal/ConnectionContext.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt index 2ab5c2cd..5345bb47 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt @@ -5,12 +5,8 @@ import androidx.sqlite.SQLiteStatement import com.powersync.PowerSyncException import com.powersync.db.SqlCursor import com.powersync.db.StatementBasedCursor -import kotlin.native.HiddenFromObjC public interface ConnectionContext { - @HiddenFromObjC - public val rawConnection: SQLiteConnection - @Throws(PowerSyncException::class) public fun execute( sql: String, @@ -40,7 +36,7 @@ public interface ConnectionContext { } internal class ConnectionContextImplementation( - override val rawConnection: SQLiteConnection, + private val rawConnection: SQLiteConnection, ) : ConnectionContext { override fun execute( sql: String, 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 4498519f..c72f9a5b 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt @@ -38,7 +38,7 @@ internal class ConnectionPool( } } - suspend fun withConnection(action: suspend (connection: SQLiteConnection) -> R): R { + suspend fun obtainConnection(): RawConnectionLease { val (connection, done) = try { available.receive() @@ -49,11 +49,7 @@ 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 { 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 52257c56..164cede9 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -4,6 +4,7 @@ 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 @@ -22,7 +23,6 @@ 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 @@ -206,17 +206,30 @@ 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 */ + @OptIn(ExperimentalPowerSyncAPI::class) private suspend fun internalReadLock(callback: (SQLiteConnection) -> R): R = withContext(dbContext) { runWrapped { - readPool.withConnection { + val connection = leaseConnection(readOnly = true) + try { catchSwiftExceptions { - val lease = RawConnectionLease(it) - callback(lease).also { lease.completed = true } + callback(connection) } + } finally { + // Closing the lease will release the connection back into the pool. + connection.close() } } } @@ -235,11 +248,11 @@ internal class InternalDatabaseImpl( } } + @OptIn(ExperimentalPowerSyncAPI::class) private suspend fun internalWriteLock(callback: (SQLiteConnection) -> R): R = withContext(dbContext) { - writeLockMutex.withLock { - val lease = RawConnectionLease(writeConnection) - + val lease = leaseConnection(readOnly = false) + try { runWrapped { catchSwiftExceptions { callback(lease) @@ -248,8 +261,10 @@ internal class InternalDatabaseImpl( // Trigger watched queries // Fire updates inside the write lock updates.fireTableUpdates() - lease.completed = true } + } finally { + // Returning the lease will unlock the writeLockMutex + lease.close() } } 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 65d6ea04..7485e8ef 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt @@ -8,7 +8,7 @@ import com.powersync.db.SqlCursor public interface PowerSyncTransaction : ConnectionContext internal class PowerSyncTransactionImpl( - override val rawConnection: SQLiteConnection, + private val rawConnection: SQLiteConnection, ) : PowerSyncTransaction, ConnectionContext { private val delegate = ConnectionContextImplementation(rawConnection) diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt index f1eb4c20..f020de45 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt @@ -8,10 +8,12 @@ import androidx.sqlite.SQLiteStatement */ internal class RawConnectionLease( private val connection: SQLiteConnection, - var completed: Boolean = false, + private val returnConnection: () -> Unit, ) : SQLiteConnection { + private var isCompleted = false + private fun checkNotCompleted() { - check(!completed) { "Connection lease already closed" } + check(!isCompleted) { "Connection lease already closed" } } override fun inTransaction(): Boolean { @@ -26,5 +28,9 @@ internal class RawConnectionLease( override fun close() { // Note: This is a lease, don't close the underlying connection. + if (!isCompleted) { + isCompleted = true + returnConnection() + } } } diff --git a/drivers/common/build.gradle.kts b/drivers/common/build.gradle.kts index f714c4b4..1c55c497 100644 --- a/drivers/common/build.gradle.kts +++ b/drivers/common/build.gradle.kts @@ -45,7 +45,7 @@ kotlin { } android { - namespace = "com.powersync.compose" + namespace = "com.powersync.drivers.common" compileSdk = libs.versions.android.compileSdk .get() 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 index 7abb9655..44bd6609 100644 --- a/drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt +++ b/drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt @@ -4,7 +4,9 @@ import android.content.Context import java.util.Properties import java.util.concurrent.atomic.AtomicBoolean -public class AndroidDriver(private val context: Context): JdbcDriver() { +public class AndroidDriver( + private val context: Context, +) : JdbcDriver() { override fun addDefaultProperties(properties: Properties) { val isFirst = IS_FIRST_CONNECTION.getAndSet(false) if (isFirst) { 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 index 3348e4a9..42206221 100644 --- a/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt +++ b/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt @@ -13,40 +13,47 @@ import org.sqlite.jdbc4.JDBC4ResultSet import java.sql.Types import java.util.Properties -public open class JdbcDriver: PowerSyncDriver { +public open class JdbcDriver : PowerSyncDriver { internal open fun addDefaultProperties(properties: Properties) {} override fun openDatabase( path: String, readOnly: Boolean, - listener: ConnectionListener? + 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 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.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 - } + 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) } @@ -64,15 +71,13 @@ public open class JdbcDriver: PowerSyncDriver { public class JdbcConnection( public val connection: org.sqlite.SQLiteConnection, -): SQLiteConnection { +) : SQLiteConnection { override fun inTransaction(): Boolean { // TODO: Unsupported with sqlite-jdbc? return true } - override fun prepare(sql: String): SQLiteStatement { - return PowerSyncStatement(connection.prepareStatement(sql) as JDBC4PreparedStatement) - } + override fun prepare(sql: String): SQLiteStatement = PowerSyncStatement(connection.prepareStatement(sql) as JDBC4PreparedStatement) override fun close() { connection.close() @@ -81,7 +86,7 @@ public class JdbcConnection( private class PowerSyncStatement( private val stmt: JDBC4PreparedStatement, -): SQLiteStatement { +) : SQLiteStatement { private var currentCursor: JDBC4ResultSet? = null private val _columnCount: Int by lazy { @@ -90,25 +95,36 @@ private class PowerSyncStatement( stmt.pointer.safeRunInt { db, ptr -> db.column_count(ptr) } } - private fun requireCursor(): JDBC4ResultSet { - return requireNotNull(currentCursor) { + 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 bindBlob( + index: Int, + value: ByteArray, + ) { + stmt.setBytes(index, value) } - override fun bindDouble(index: Int, value: Double) { + override fun bindDouble( + index: Int, + value: Double, + ) { stmt.setDouble(index, value) } - override fun bindLong(index: Int, value: Long) { + override fun bindLong( + index: Int, + value: Long, + ) { stmt.setLong(index, value) } - override fun bindText(index: Int, value: String) { + override fun bindText( + index: Int, + value: String, + ) { stmt.setString(index, value) } @@ -116,37 +132,21 @@ private class PowerSyncStatement( stmt.setNull(index, Types.NULL) } - override fun getBlob(index: Int): ByteArray { - return requireCursor().getBytes(index + 1) - } + override fun getBlob(index: Int): ByteArray = requireCursor().getBytes(index + 1) - override fun getDouble(index: Int): Double { - return requireCursor().getDouble(index + 1) - } + override fun getDouble(index: Int): Double = requireCursor().getDouble(index + 1) - override fun getLong(index: Int): Long { - return requireCursor().getLong(index + 1) - } + override fun getLong(index: Int): Long = requireCursor().getLong(index + 1) - override fun getText(index: Int): String { - return requireCursor().getString(index + 1) - } + override fun getText(index: Int): String = requireCursor().getString(index + 1) - override fun isNull(index: Int): Boolean { - return getColumnType(index) == SQLITE_DATA_NULL - } + override fun isNull(index: Int): Boolean = getColumnType(index) == SQLITE_DATA_NULL - override fun getColumnCount(): Int { - return _columnCount - } + override fun getColumnCount(): Int = _columnCount - override fun getColumnName(index: Int): String { - return stmt.metaData.getColumnName(index + 1) - } + override fun getColumnName(index: Int): String = stmt.metaData.getColumnName(index + 1) - override fun getColumnType(index: Int): Int { - return stmt.pointer.safeRunInt { db, ptr -> db.column_type(ptr, index ) } - } + override fun getColumnType(index: Int): Int = stmt.pointer.safeRunInt { db, ptr -> db.column_type(ptr, index) } override fun step(): Boolean { if (currentCursor == null) { 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 index 0bb0f34c..4baa7535 100644 --- a/drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt +++ b/drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt @@ -19,6 +19,13 @@ public interface PowerSyncDriver { public interface ConnectionListener { public fun onCommit() + public fun onRollback() - public fun onUpdate(kind: Int, database: String, table: String, rowid: Long) + + 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 index 9cf78c9a..581d5e8f 100644 --- a/drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt +++ b/drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt @@ -36,11 +36,12 @@ public class NativeDriver : PowerSyncDriver { readOnly: Boolean, listener: ConnectionListener?, ): NativeConnection { - val flags = if (readOnly) { - SQLITE_OPEN_READONLY - } else { - SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE - } + val flags = + if (readOnly) { + SQLITE_OPEN_READONLY + } else { + SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE + } return memScoped { val dbPointer = allocPointerTo() @@ -58,22 +59,19 @@ public class NativeDriver : PowerSyncDriver { public class NativeConnection( public val sqlite: CPointer, - listener: ConnectionListener? -): SQLiteConnection { + 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()) - } + 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 { - return inner.inTransaction() - } + override fun inTransaction(): Boolean = inner.inTransaction() - override fun prepare(sql: String): SQLiteStatement { - return inner.prepare(sql) - } + override fun prepare(sql: String): SQLiteStatement = inner.prepare(sql) override fun close() { inner.close() @@ -96,13 +94,13 @@ private val rollbackHook = private val updateHook = staticCFunction< - COpaquePointer?, - Int, - CPointer?, - CPointer?, - Long, - Unit, - > { ctx, type, db, table, rowId -> + COpaquePointer?, + Int, + CPointer?, + CPointer?, + Long, + Unit, + > { ctx, type, db, table, rowId -> val listener = ctx!!.asStableRef().get() listener.onUpdate( type, From 331d8b19b389f5fb5454d0b10db374d3592890ed Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 25 Jul 2025 11:02:55 +0200 Subject: [PATCH 6/7] Start adding room driver --- build.gradle.kts | 1 + gradle/libs.versions.toml | 8 ++ integrations/room/README.md | 9 ++ integrations/room/build.gradle.kts | 46 ++++++++++ .../integrations/room/PowerSyncRoomDriver.kt | 83 +++++++++++++++++++ settings.gradle.kts | 1 + 6 files changed, 148 insertions(+) create mode 100644 integrations/room/README.md create mode 100644 integrations/room/build.gradle.kts create mode 100644 integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncRoomDriver.kt diff --git a/build.gradle.kts b/build.gradle.kts index e7477f6b..31801946 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,7 @@ plugins { 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index de6c06c7..ea44892b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ java = "17" # 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" @@ -29,6 +30,7 @@ junit = "4.13.2" compose = "1.6.11" compose-preview = "1.7.8" androidxSqlite = "2.6.0-alpha01" +room = "2.7.2" # plugins android-gradle-plugin = "8.10.1" @@ -95,6 +97,11 @@ supabase-storage = { module = "io.github.jan-tennert.supabase:storage-kt", versi 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" } @@ -121,3 +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" } +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..d9b58751 --- /dev/null +++ b/integrations/room/build.gradle.kts @@ -0,0 +1,46 @@ +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 { + commonMain.dependencies { + api(projects.core) + + api(libs.androidx.sqlite) + api(libs.androidx.room.runtime) + } + } +} + +dependencies { + // We use a room database for testing, so we apply the symbol processor on the test target. + add("kspTest", 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/commonMain/kotlin/com/powersync/integrations/room/PowerSyncRoomDriver.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncRoomDriver.kt new file mode 100644 index 00000000..ad5678c6 --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncRoomDriver.kt @@ -0,0 +1,83 @@ +package com.powersync.integrations.room + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import com.powersync.PowerSyncDatabase +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +public class PowerSyncRoomDriver( + private val db: PowerSyncDatabase, + private val scope: CoroutineScope, +): 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, scope) + } +} + +private class PowerSyncConnection( + private val db: PowerSyncDatabase, + private val scope: CoroutineScope, +): 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 currentConnection: LeasedConnection? = null + + private fun obtainConnection(): SQLiteConnection { + currentConnection?.let { + return it.inner + } + + val completeConnection = CompletableDeferred() + + scope.launch { + db.writeLock { inner -> + val connectionReturned = CompletableDeferred() + val lease = LeasedConnection(inner.rawConnection, connectionReturned) + completeConnection.complete(lease.inner) + + connectionReturned.await() + } + } + + return runBlocking { completeConnection.await() } + } + + private fun returnConnection() { + currentConnection?.returnConnection() + currentConnection = null + } + + override fun prepare(sql: String): SQLiteStatement { + val completeConnection = CompletableDeferred() + + scope.launch { + db.writeLock { inner -> + val connection = inner.rawConnection + + } + } + + TODO("Not yet implemented") + } + + override fun close() { + returnConnection() + } +} + +private class LeasedConnection( + val inner: SQLiteConnection, + val complete: CompletableDeferred +) { + fun returnConnection() { + complete.complete(Unit) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 51df18b0..940aca15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,5 +31,6 @@ include(":PowerSyncKotlin") include(":drivers:common") include(":compose") +include(":integrations:room") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") From 82ad2dc7bb96d38a6060ccd0a6d433d3c856353f Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 25 Jul 2025 13:34:46 +0200 Subject: [PATCH 7/7] Try to use Room --- integrations/room/build.gradle.kts | 20 ++- .../integrations/room/TestUtils.android.kt | 10 ++ .../integrations/room/TestUtils.apple.kt | 16 ++ .../com/powersync/integrations/room/Driver.kt | 162 ++++++++++++++++++ .../room/PowerSyncInvalidationTracker.kt | 6 + .../room/PowerSyncOpenDelegate.kt | 31 ++++ .../integrations/room/PowerSyncRoomDriver.kt | 83 --------- .../powersync/integrations/room/DriverTest.kt | 47 +++++ .../integrations/room/TestDatabase.kt | 72 ++++++++ .../powersync/integrations/room/TestUtils.kt | 8 + .../integrations/room/TestUtils.jvm.kt | 16 ++ 11 files changed, 387 insertions(+), 84 deletions(-) create mode 100644 integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/TestUtils.android.kt create mode 100644 integrations/room/src/appleTest/kotlin/com/powersync/integrations/room/TestUtils.apple.kt create mode 100644 integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/Driver.kt create mode 100644 integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncInvalidationTracker.kt create mode 100644 integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncOpenDelegate.kt delete mode 100644 integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncRoomDriver.kt create mode 100644 integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/DriverTest.kt create mode 100644 integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt create mode 100644 integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestUtils.kt create mode 100644 integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/TestUtils.jvm.kt diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts index d9b58751..026088ca 100644 --- a/integrations/room/build.gradle.kts +++ b/integrations/room/build.gradle.kts @@ -14,18 +14,36 @@ kotlin { 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. - add("kspTest", libs.androidx.room.compiler) + 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 { 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/commonMain/kotlin/com/powersync/integrations/room/PowerSyncRoomDriver.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncRoomDriver.kt deleted file mode 100644 index ad5678c6..00000000 --- a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncRoomDriver.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.powersync.integrations.room - -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.SQLiteDriver -import androidx.sqlite.SQLiteStatement -import com.powersync.PowerSyncDatabase -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -public class PowerSyncRoomDriver( - private val db: PowerSyncDatabase, - private val scope: CoroutineScope, -): 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, scope) - } -} - -private class PowerSyncConnection( - private val db: PowerSyncDatabase, - private val scope: CoroutineScope, -): 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 currentConnection: LeasedConnection? = null - - private fun obtainConnection(): SQLiteConnection { - currentConnection?.let { - return it.inner - } - - val completeConnection = CompletableDeferred() - - scope.launch { - db.writeLock { inner -> - val connectionReturned = CompletableDeferred() - val lease = LeasedConnection(inner.rawConnection, connectionReturned) - completeConnection.complete(lease.inner) - - connectionReturned.await() - } - } - - return runBlocking { completeConnection.await() } - } - - private fun returnConnection() { - currentConnection?.returnConnection() - currentConnection = null - } - - override fun prepare(sql: String): SQLiteStatement { - val completeConnection = CompletableDeferred() - - scope.launch { - db.writeLock { inner -> - val connection = inner.rawConnection - - } - } - - TODO("Not yet implemented") - } - - override fun close() { - returnConnection() - } -} - -private class LeasedConnection( - val inner: SQLiteConnection, - val complete: CompletableDeferred -) { - fun returnConnection() { - complete.complete(Unit) - } -} \ 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) } + ) + } +}