From 136e809943f3f0c8393577e42a09fbf8181d5716 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 22 Jul 2025 23:17:15 +0200 Subject: [PATCH 01/28] New unified driver interfaces --- build.gradle.kts | 2 - compose/build.gradle.kts | 1 - .../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 | 24 +- 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 +- 44 files changed, 735 insertions(+), 1767 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 cb631031..b1ec5971 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 29d2f75e..85af4b94 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -24,7 +24,6 @@ kotlin { sourceSets { commonMain.dependencies { api(project(":core")) - implementation(project(":persistence")) implementation(compose.runtime) } androidMain.dependencies { diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 8eba77b2..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 c91b3aae..da931ad3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,6 @@ configurationAnnotations = "0.10.4" dokkaBase = "2.0.0" gradleDownloadTask = "5.6.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.6" @@ -19,11 +18,9 @@ ktor = "3.2.3" uuid = "0.8.4" powersync-core = "0.4.4" sqlite-jdbc = "3.50.3.0" -sqliter = "1.3.3" turbine = "1.2.1" kotest = "5.9.1" -sqlDelight = "2.1.0" stately = "2.1.0" supabase = "3.2.2" junit = "4.13.2" @@ -32,7 +29,7 @@ compose = "1.8.2" # This is for the multiplatform compose androidCompose = "2025.07.00" compose-preview = "1.8.3" compose-lifecycle = "2.9.1" -androidxSqlite = "2.5.2" +androidxSqlite = "2.6.0-rc01" androidxSplashscreen = "1.0.1" # plugins @@ -96,22 +93,13 @@ ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } -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 @@ -151,8 +139,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" } @@ -161,9 +147,3 @@ 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" } buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildKonfig" } - -[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 cea7015b..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.android.library) - 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 2e885f43..3d4c4c4c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,10 +32,10 @@ include(":core-tests-android") include(":connectors:supabase") include("static-sqlite-driver") -include(":dialect") -include(":persistence") include(":PowerSyncKotlin") +include(":drivers:common") + include(":compose") include(":demos:android-supabase-todolist") 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 435c7c5a271fe4e299cd4bea4b2d1c01a343315f Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 24 Jul 2025 09:21:39 +0200 Subject: [PATCH 02/28] Fix leaking statements --- core/build.gradle.kts | 1 + .../DatabaseDriverFactory.android.kt | 2 - .../powersync/DatabaseDriverFactory.apple.kt | 205 ++++-------------- .../kotlin/com/powersync/DeferredDriver.kt | 27 --- .../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 +- 19 files changed, 173 insertions(+), 315 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 520ab811..68dece2b 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -233,6 +233,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/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 522f4c64b8f649cc34b2fd0583e98591c49ca7ed Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 25 Jul 2025 09:52:02 +0200 Subject: [PATCH 03/28] Add raw connection API --- core/build.gradle.kts | 3 + .../DatabaseDriverFactory.android.kt | 2 +- .../powersync/DatabaseDriverFactory.apple.kt | 11 ++-- .../kotlin/com/powersync/DatabaseTest.kt | 37 ++++++++++++ .../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 +- 12 files changed, 163 insertions(+), 86 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 68dece2b..9b6bfcfe 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -183,6 +183,7 @@ kotlin { languageSettings { optIn("kotlinx.cinterop.ExperimentalForeignApi") optIn("kotlin.time.ExperimentalTime") + optIn("kotlin.experimental.ExperimentalObjCRefinement") } } @@ -203,6 +204,8 @@ kotlin { } dependencies { + api(libs.androidx.sqlite) + implementation(libs.uuid) implementation(libs.kotlin.stdlib) implementation(libs.ktor.client.contentnegotiation) 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 96e7ff73..17b669c1 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -1,6 +1,8 @@ package com.powersync import app.cash.turbine.test +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi import com.powersync.db.ActiveDatabaseGroup @@ -16,6 +18,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 @@ -495,4 +498,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/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index b3582e63..2f8c5af3 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 @@ -43,7 +44,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant -import kotlin.math.log import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant 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 2359b48d0c5f381a2e0f48b10624899bd23a28f5 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 25 Jul 2025 10:24:54 +0200 Subject: [PATCH 04/28] Add changelog entry --- CHANGELOG.md | 3 +++ .../kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt | 1 - .../kotlin/com/powersync/db/internal/PowerSyncTransaction.kt | 2 +- drivers/README.md | 3 +++ gradle/libs.versions.toml | 1 - 5 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 drivers/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index db1f144a..2c22a113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ to upload multiple transactions in a batch. * Fix modifying severity of the global Kermit logger * Add `PowerSync` tag for the logs +* Remove internal SQLDelight and SQLiter dependencies. +* 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.4.0 diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 2f8c5af3..250d4924 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 da931ad3..a67168b8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,6 @@ android-gradle-plugin = "8.11.1" skie = "0.10.5" maven-publish = "0.34.0" download-plugin = "5.6.0" -grammarkit-composer = "0.1.12" mokkery = "2.9.0" kotlinter = "5.1.1" keeper = "0.16.1" From f4d98d50dc905cbcd57c985a8a5fad27b825491d Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 25 Jul 2025 11:40:10 +0200 Subject: [PATCH 05/28] Lease API that works better with Room --- .../kotlin/com/powersync/DatabaseTest.kt | 58 ++++++--- .../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(+), 130 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 17b669c1..3bb4a0be 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -8,6 +8,7 @@ import co.touchlab.kermit.ExperimentalKermitApi import com.powersync.db.ActiveDatabaseGroup import com.powersync.db.crud.CrudEntry import com.powersync.db.crud.CrudTransaction +import com.powersync.db.getString import com.powersync.db.schema.Schema import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest @@ -18,17 +19,18 @@ 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.flow.takeWhile +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 { @@ -500,36 +502,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 250d4924..39a51437 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 @@ -329,6 +331,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 eccb7f7314fabefffe927a5cbd74452a24b69dd3 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 25 Jul 2025 15:35:34 +0200 Subject: [PATCH 06/28] Notify updates from raw statements --- .../db/internal/InternalDatabaseImpl.kt | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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 164cede9..fdcce6fd 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.milliseconds @@ -212,9 +213,24 @@ internal class InternalDatabaseImpl( readPool.obtainConnection() } else { writeLockMutex.lock() - RawConnectionLease(writeConnection, writeLockMutex::unlock) + RawConnectionLease(writeConnection) { + scope.launch { + // When we've leased a write connection, we may have to update table update + // flows after users ran their custom statements. + // For internal queries, this happens with leaseWrite() and an asynchronous call + // in internalWriteLock + updates.fireTableUpdates() + } + + writeLockMutex.unlock() + } } + private suspend fun leaseWrite(): SQLiteConnection { + writeLockMutex.lock() + return RawConnectionLease(writeConnection, writeLockMutex::unlock) + } + /** * Creates a read lock while providing an internal transactor for transactions */ @@ -251,7 +267,7 @@ internal class InternalDatabaseImpl( @OptIn(ExperimentalPowerSyncAPI::class) private suspend fun internalWriteLock(callback: (SQLiteConnection) -> R): R = withContext(dbContext) { - val lease = leaseConnection(readOnly = false) + val lease = leaseWrite() try { runWrapped { catchSwiftExceptions { From 54829bc0b6f86d97c0dfee371b7ab173df45c6f5 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 22 Aug 2025 11:41:45 +0200 Subject: [PATCH 07/28] Delete more driver stuff --- core/build.gradle.kts | 77 ++------ .../DatabaseDriverFactory.android.kt | 30 +-- .../powersync/DatabaseDriverFactory.apple.kt | 114 ++--------- .../DatabaseDriverFactory.appleNonWatchOs.kt | 32 ++++ .../kotlin/com/powersync/DatabaseTest.kt | 5 +- .../kotlin/com/powersync/db/LoadExtension.kt | 17 -- .../com/powersync/DatabaseDriverFactory.kt | 37 +++- .../kotlin/com/powersync/PowerSyncDatabase.kt | 31 +++ .../com/powersync/PowerSyncDatabaseFactory.kt | 30 ++- .../com/powersync/db/ActiveInstanceStore.kt | 8 +- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 31 ++- .../kotlin/com/powersync/db/Queries.kt | 10 +- .../db/driver/InternalConnectionPool.kt | 113 +++++++++++ .../powersync/db/driver/RawConnectionLease.kt | 50 +++++ .../ConnectionPool.kt => driver/ReadPool.kt} | 10 +- .../db/driver/SQLiteConnectionPool.kt | 58 ++++++ .../db/internal/ConnectionContext.kt | 64 ++++--- .../db/internal/InternalDatabaseImpl.kt | 110 ++--------- .../db/internal/PowerSyncTransaction.kt | 18 +- .../db/internal/RawConnectionLease.kt | 36 ---- .../com/powersync/db/internal/UpdateFlow.kt | 49 ----- .../powersync/DatabaseDriverFactory.ios.kt | 7 - .../powersync/DatabaseDriverFactory.jvm.kt | 30 +-- .../powersync/DatabaseDriverFactory.macos.kt | 7 - .../powersync/DatabaseDriverFactory.tvos.kt | 7 - .../DatabaseDriverFactory.watchos.kt | 37 ++-- .../build.gradle.kts | 1 - drivers/README.md | 3 - drivers/common/build.gradle.kts | 62 ------ .../internal/driver/AndroidDriver.kt | 26 --- .../powersync/internal/driver/JdbcDriver.kt | 179 ------------------ .../internal/driver/PowerSyncDriver.kt | 31 --- .../powersync/internal/driver/NativeDriver.kt | 111 ----------- gradle/libs.versions.toml | 7 +- .../com/powersync/compile/ClangCompile.kt | 114 ----------- .../powersync/compile/CreateSqliteCInterop.kt | 39 ---- .../powersync/compile/CreateStaticLibrary.kt | 37 ---- .../com/powersync/compile/UnzipSqlite.kt | 35 ---- settings.gradle.kts | 3 - static-sqlite-driver/README.md | 1 - static-sqlite-driver/build.gradle.kts | 113 ----------- static-sqlite-driver/gradle.properties | 3 - .../powersync/sqlite3/StaticSqliteDriver.kt | 9 - .../src/nativeTest/kotlin/SmokeTest.kt | 15 -- 44 files changed, 490 insertions(+), 1317 deletions(-) create mode 100644 core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt delete mode 100644 core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt create mode 100644 core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt create mode 100644 core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt rename core/src/commonMain/kotlin/com/powersync/db/{internal/ConnectionPool.kt => driver/ReadPool.kt} (92%) create mode 100644 core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt delete mode 100644 core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt delete mode 100644 core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt delete mode 100644 core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt delete mode 100644 core/src/macosMain/kotlin/com/powersync/DatabaseDriverFactory.macos.kt delete mode 100644 core/src/tvosMain/kotlin/com/powersync/DatabaseDriverFactory.tvos.kt delete mode 100644 drivers/README.md delete mode 100644 drivers/common/build.gradle.kts delete mode 100644 drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt delete mode 100644 drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt delete mode 100644 drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt delete mode 100644 drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt delete mode 100644 plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt delete mode 100644 plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateSqliteCInterop.kt delete mode 100644 plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateStaticLibrary.kt delete mode 100644 plugins/build-plugin/src/main/kotlin/com/powersync/compile/UnzipSqlite.kt delete mode 100644 static-sqlite-driver/README.md delete mode 100644 static-sqlite-driver/build.gradle.kts delete mode 100644 static-sqlite-driver/gradle.properties delete mode 100644 static-sqlite-driver/src/commonMain/kotlin/com/powersync/sqlite3/StaticSqliteDriver.kt delete mode 100644 static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 9b6bfcfe..c6c24b5f 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -74,60 +74,6 @@ val downloadPowersyncDesktopBinaries by tasks.registering(Download::class) { onlyIfModified(true) } -val sqliteJDBCFolder = - project.layout.buildDirectory - .dir("jdbc") - .get() - -val jniLibsFolder = layout.projectDirectory.dir("src/androidMain/jni") - -val downloadJDBCJar by tasks.registering(Download::class) { - val version = - libs.versions.sqlite.jdbc - .get() - val jar = - "https://github.com/xerial/sqlite-jdbc/releases/download/$version/sqlite-jdbc-$version.jar" - - src(jar) - dest(sqliteJDBCFolder.file("jdbc.jar")) - onlyIfModified(true) -} - -val extractJDBCJNI by tasks.registering(Copy::class) { - dependsOn(downloadJDBCJar) - - from( - zipTree(downloadJDBCJar.get().dest).matching { - include("org/sqlite/native/Linux-Android/**") - }, - ) - - into(sqliteJDBCFolder.dir("jni")) -} - -val moveJDBCJNIFiles by tasks.registering(Copy::class) { - dependsOn(extractJDBCJNI) - - val abiMapping = - mapOf( - "aarch64" to "arm64-v8a", - "arm" to "armeabi-v7a", - "x86_64" to "x86_64", - "x86" to "x86", - ) - - abiMapping.forEach { (sourceABI, androidABI) -> - from(sqliteJDBCFolder.dir("jni/org/sqlite/native/Linux-Android/$sourceABI")) { - include("*.so") - eachFile { - path = "$androidABI/$name" - } - } - } - - into(jniLibsFolder) // Move everything into the base jniLibs folder -} - val generateVersionConstant by tasks.registering { val target = project.layout.buildDirectory.dir("generated/constants") val packageName = "com.powersync.build" @@ -193,9 +139,6 @@ kotlin { val commonJava by creating { dependsOn(commonMain.get()) - dependencies { - implementation(libs.sqlite.jdbc) - } } commonMain.configure { @@ -204,7 +147,7 @@ kotlin { } dependencies { - api(libs.androidx.sqlite) + api(libs.androidx.sqlite.sqlite) implementation(libs.uuid) implementation(libs.kotlin.stdlib) @@ -215,7 +158,7 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.stately.concurrency) implementation(libs.configuration.annotations) - api(projects.persistence) + implementation(libs.androidx.sqlite.bundled) api(libs.ktor.client.core) api(libs.kermit) } @@ -236,9 +179,17 @@ kotlin { appleMain.dependencies { implementation(libs.ktor.client.darwin) - implementation(projects.staticSqliteDriver) } + // Common apple targets where we link the core extension dynamically + val appleNonWatchOsMain by creating { + dependsOn(appleMain.get()) + } + + macosMain.orNull?.dependsOn(appleNonWatchOsMain) + iosMain.orNull?.dependsOn(appleNonWatchOsMain) + tvosMain.orNull?.dependsOn(appleNonWatchOsMain) + commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.test.coroutines) @@ -298,12 +249,6 @@ android { ndkVersion = "27.1.12297006" } -androidComponents.onVariants { - tasks.named("preBuild") { - dependsOn(moveJDBCJNIFiles) - } -} - tasks.named(kotlin.jvm().compilations["main"].processResourcesTaskName) { from(downloadPowersyncDesktopBinaries) } diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 3b593785..7087ac1d 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -1,35 +1,17 @@ package com.powersync import android.content.Context -import androidx.sqlite.SQLiteConnection -import com.powersync.db.loadExtensions -import com.powersync.internal.driver.AndroidDriver -import com.powersync.internal.driver.ConnectionListener -import com.powersync.internal.driver.JdbcConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public actual class DatabaseDriverFactory( private val context: Context, ) { - internal actual fun openDatabase( - dbFilename: String, - dbDirectory: String?, - readOnly: Boolean, - listener: ConnectionListener?, - ): SQLiteConnection { - val dbPath = - if (dbDirectory != null) { - "$dbDirectory/$dbFilename" - } else { - "${context.getDatabasePath(dbFilename)}" - } - - val driver = AndroidDriver(context) - val connection = driver.openDatabase(dbPath, readOnly, listener) as JdbcConnection - connection.loadExtensions( - "libpowersync.so" to "sqlite3_powersync_init", - ) + internal actual fun addPowerSyncExtension(driver: BundledSQLiteDriver) { + driver.addExtension("libpowersync.so", "sqlite3_powersync_init") + } - return connection + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { + return context.getDatabasePath(dbFilename).path } } diff --git a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt index d5ab6c71..a168741b 100644 --- a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt +++ b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt @@ -1,116 +1,24 @@ package com.powersync -import androidx.sqlite.SQLiteConnection -import com.powersync.DatabaseDriverFactory.Companion.powerSyncExtensionPath -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.UnsafeNumber -import kotlinx.cinterop.alloc -import kotlinx.cinterop.free -import kotlinx.cinterop.nativeHeap -import kotlinx.cinterop.ptr -import kotlinx.cinterop.toKString -import kotlinx.cinterop.value -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 openDatabase( - dbFilename: String, - dbDirectory: String?, - readOnly: Boolean, - listener: ConnectionListener?, - ): SQLiteConnection { - val directory = dbDirectory ?: defaultDatabaseDirectory() - val path = Path(directory, dbFilename).toString() - val db = NativeDriver().openNativeDatabase(path, readOnly, listener) +@OptIn(UnsafeNumber::class) +internal fun appleDefaultDatabasePath(dbFilename: String): 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 - db.loadPowerSyncSqliteCoreExtension() - return db - } + val databaseDirectory = "$documentsDirectory/databases" - internal companion object { - internal val powerSyncExtensionPath by lazy { - // Try and find the bundle path for the SQLite core extension. - val bundlePath = - NSBundle.bundleWithIdentifier("co.powersync.sqlitecore")?.bundlePath - ?: // The bundle is not installed in the project - throw PowerSyncException( - "Please install the PowerSync SQLite core extension", - cause = Exception("The `co.powersync.sqlitecore` bundle could not be found in the project."), - ) + val fileManager = NSFileManager.defaultManager() - // Construct full path to the shared library inside the bundle - bundlePath.let { "$it/powersync-sqlite-core" } - } + if (!fileManager.fileExistsAtPath(databaseDirectory)) { + fileManager.createDirectoryAtPath(databaseDirectory, true, null, null) + }; // Create folder - @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 - } - } + return databaseDirectory } - -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 != SQLITE_OK) { - throw PowerSyncException( - "Could not dynamically load the PowerSync SQLite core extension", - cause = - Exception( - "Call to sqlite3_enable_load_extension failed", - ), - ) - } - - // A place to store a potential error message response - val errMsg = nativeHeap.alloc>() - val result = - sqlite3_load_extension(ptr, extensionPath, "sqlite3_powersync_init", errMsg.ptr) - val resultingError = errMsg.value - nativeHeap.free(errMsg) - if (result != SQLITE_OK) { - val errorMessage = resultingError?.toKString() ?: "Unknown error" - throw PowerSyncException( - "Could not load the PowerSync SQLite core extension", - cause = - Exception( - "Calling sqlite3_load_extension failed with error: $errorMessage", - ), - ) - } -} - -internal expect fun NativeConnection.loadPowerSyncSqliteCoreExtension() diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt new file mode 100644 index 00000000..805b8457 --- /dev/null +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -0,0 +1,32 @@ +package com.powersync + +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import platform.Foundation.NSBundle +import kotlin.getValue + +@Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) +public actual class DatabaseDriverFactory { + internal actual fun addPowerSyncExtension(driver: BundledSQLiteDriver) { + driver.addExtension(powerSyncExtensionPath, "sqlite3_powersync_init") + } + + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { + return appleDefaultDatabasePath(dbFilename) + } + + private companion object { + val powerSyncExtensionPath by lazy { + // Try and find the bundle path for the SQLite core extension. + val bundlePath = + NSBundle.bundleWithIdentifier("co.powersync.sqlitecore")?.bundlePath + ?: // The bundle is not installed in the project + throw PowerSyncException( + "Please install the PowerSync SQLite core extension", + cause = Exception("The `co.powersync.sqlitecore` bundle could not be found in the project."), + ) + + // Construct full path to the shared library inside the bundle + bundlePath.let { "$it/powersync-sqlite-core" } + } + } +} diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 3bb4a0be..b40440d5 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -510,8 +510,9 @@ class DatabaseTest { listOf("a", "a@example.org"), ) + val raw = database.leaseConnection(readOnly = true) - raw.prepare("SELECT * FROM users").use { stmt -> + raw.usePrepared("SELECT * FROM users") { stmt -> stmt.step() shouldBe true stmt.getText(1) shouldBe "a" stmt.getText(2) shouldBe "a@example.org" @@ -524,7 +525,7 @@ class DatabaseTest { fun testLeaseWrite() = databaseTest { val raw = database.leaseConnection(readOnly = false) - raw.prepare("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)").use { stmt -> + raw.usePrepared("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)") { stmt -> stmt.bindText(1, "name") stmt.bindText(2, "email") stmt.step() shouldBe false diff --git a/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt b/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt deleted file mode 100644 index 42febe15..00000000 --- a/core/src/commonJava/kotlin/com/powersync/db/LoadExtension.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.powersync.db - -import com.powersync.internal.driver.JdbcConnection - -internal fun JdbcConnection.loadExtensions(vararg extensions: Pair) { - connection.database.enable_load_extension(true) - extensions.forEach { (path, entryPoint) -> - val executed = - connection.prepareStatement("SELECT load_extension(?, ?);").use { statement -> - statement.setString(1, path) - statement.setString(2, entryPoint) - statement.execute() - } - check(executed) { "load_extension(\"${path}\", \"${entryPoint}\") failed" } - } - connection.database.enable_load_extension(false) -} diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index cce71f19..47b5b025 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -1,14 +1,37 @@ package com.powersync import androidx.sqlite.SQLiteConnection -import com.powersync.internal.driver.ConnectionListener +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.driver.bundled.SQLITE_OPEN_CREATE +import androidx.sqlite.driver.bundled.SQLITE_OPEN_READONLY +import androidx.sqlite.driver.bundled.SQLITE_OPEN_READWRITE @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public expect class DatabaseDriverFactory { - internal fun openDatabase( - dbFilename: String, - dbDirectory: String?, - readOnly: Boolean = false, - listener: ConnectionListener?, - ): SQLiteConnection + internal fun addPowerSyncExtension(driver: BundledSQLiteDriver) + + internal fun resolveDefaultDatabasePath(dbFilename: String): String +} + +internal fun openDatabase( + factory: DatabaseDriverFactory, + dbFilename: String, + dbDirectory: String?, + readOnly: Boolean = false, +): SQLiteConnection { + val driver = BundledSQLiteDriver() + val dbPath = + if (dbDirectory != null) { + "$dbDirectory/$dbFilename" + } else { + factory.resolveDefaultDatabasePath(dbFilename) + } + + factory.addPowerSyncExtension(driver) + + return driver.open(dbPath, if (readOnly) { + SQLITE_OPEN_READONLY + } else { + SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE + }) } diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index daa60697..178aae69 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -1,14 +1,20 @@ package com.powersync +import co.touchlab.kermit.Logger import com.powersync.bucket.BucketPriority import com.powersync.connectors.PowerSyncBackendConnector +import com.powersync.db.ActiveDatabaseGroup +import com.powersync.db.ActiveDatabaseResource +import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.Queries import com.powersync.db.crud.CrudBatch import com.powersync.db.crud.CrudTransaction +import com.powersync.db.driver.SQLiteConnectionPool import com.powersync.db.schema.Schema import com.powersync.sync.SyncOptions import com.powersync.sync.SyncStatus import com.powersync.utils.JsonParam +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlin.coroutines.cancellation.CancellationException @@ -203,4 +209,29 @@ public interface PowerSyncDatabase : Queries { */ @Throws(PowerSyncException::class, CancellationException::class) public suspend fun close() + + public companion object { + /** + * Creates a PowerSync database managed by an external connection pool. + * + * In this case, PowerSync will not open its own SQLite connections, but rather refer to + * connections in the [pool]. + */ + @ExperimentalPowerSyncAPI + public fun opened( + pool: SQLiteConnectionPool, + scope: CoroutineScope, + schema: Schema, + group: Pair, + logger: Logger, + ): PowerSyncDatabase { + return PowerSyncDatabaseImpl( + schema, + scope, + pool, + logger, + group, + ) + } + } } diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt index bd6fc453..823af1e7 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt @@ -2,7 +2,9 @@ package com.powersync import co.touchlab.kermit.Logger import co.touchlab.skie.configuration.annotations.DefaultArgumentInterop +import com.powersync.db.ActiveDatabaseGroup import com.powersync.db.PowerSyncDatabaseImpl +import com.powersync.db.driver.InternalConnectionPool import com.powersync.db.schema.Schema import com.powersync.utils.generateLogger import kotlinx.coroutines.CoroutineScope @@ -40,6 +42,7 @@ public fun PowerSyncDatabase( ) } +@OptIn(ExperimentalPowerSyncAPI::class) internal fun createPowerSyncDatabaseImpl( factory: DatabaseDriverFactory, schema: Schema, @@ -47,12 +50,23 @@ internal fun createPowerSyncDatabaseImpl( scope: CoroutineScope, logger: Logger, dbDirectory: String?, -): PowerSyncDatabaseImpl = - PowerSyncDatabaseImpl( - schema = schema, - factory = factory, - dbFilename = dbFilename, - scope = scope, - logger = logger, - dbDirectory = dbDirectory, +): PowerSyncDatabaseImpl { + val identifier = dbDirectory + dbFilename + val activeDatabaseGroup = ActiveDatabaseGroup.referenceDatabase(logger, identifier) + + val pool = InternalConnectionPool( + factory, + scope, + dbFilename, + dbDirectory, + activeDatabaseGroup.first.group.writeLockMutex ) + + return PowerSyncDatabase.opened( + pool, + scope, + schema, + activeDatabaseGroup, + logger, + ) as PowerSyncDatabaseImpl +} diff --git a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt index 1ba3faed..29f7322c 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt @@ -82,12 +82,12 @@ internal class ActiveDatabaseGroup( } } -internal class ActiveDatabaseResource( - val group: ActiveDatabaseGroup, +public class ActiveDatabaseResource internal constructor( + internal val group: ActiveDatabaseGroup, ) { - val disposed = AtomicBoolean(false) + internal val disposed = AtomicBoolean(false) - fun dispose() { + internal fun dispose() { if (disposed.compareAndSet(false, true)) { group.removeUsage() } diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 39a51437..1642d73c 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -1,8 +1,6 @@ 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 @@ -14,6 +12,8 @@ import com.powersync.db.crud.CrudBatch import com.powersync.db.crud.CrudEntry import com.powersync.db.crud.CrudRow import com.powersync.db.crud.CrudTransaction +import com.powersync.db.driver.SQLiteConnectionLease +import com.powersync.db.driver.SQLiteConnectionPool import com.powersync.db.internal.InternalDatabaseImpl import com.powersync.db.internal.InternalTable import com.powersync.db.internal.PowerSyncVersion @@ -57,13 +57,13 @@ import kotlin.time.Instant * * All changes to local tables are automatically recorded, whether connected or not. Once connected, the changes are uploaded. */ +@OptIn(ExperimentalPowerSyncAPI::class) internal class PowerSyncDatabaseImpl( var schema: Schema, val scope: CoroutineScope, - val factory: DatabaseDriverFactory, - private val dbFilename: String, - private val dbDirectory: String? = null, - val logger: Logger = Logger, + pool: SQLiteConnectionPool, + val logger: Logger, + private val activeDatabaseGroup: Pair, ) : PowerSyncDatabase { companion object { internal val streamConflictMessage = @@ -76,21 +76,12 @@ internal class PowerSyncDatabaseImpl( """.trimIndent() } - override val identifier = dbDirectory + dbFilename + override val identifier: String + get() = activeDatabaseGroup.first.group.identifier - private val activeDatabaseGroup = ActiveDatabaseGroup.referenceDatabase(logger, identifier) private val resource = activeDatabaseGroup.first - private val clearResourceWhenDisposed = activeDatabaseGroup.second - - private val internalDb = - InternalDatabaseImpl( - factory = factory, - scope = scope, - dbFilename = dbFilename, - dbDirectory = dbDirectory, - writeLockMutex = resource.group.writeLockMutex, - logger = logger, - ) + + private val internalDb = InternalDatabaseImpl(pool) internal val bucketStorage: BucketStorage = BucketStorageImpl(internalDb, logger) @@ -332,7 +323,7 @@ internal class PowerSyncDatabaseImpl( } @ExperimentalPowerSyncAPI - override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnection { +override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnectionLease { waitReady() return internalDb.leaseConnection(readOnly) } diff --git a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt index 72cefb40..ab9f811c 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt @@ -1,8 +1,8 @@ package com.powersync.db -import androidx.sqlite.SQLiteConnection import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException +import com.powersync.db.driver.SQLiteConnectionLease import com.powersync.db.internal.ConnectionContext import com.powersync.db.internal.PowerSyncTransaction import kotlinx.coroutines.flow.Flow @@ -192,9 +192,9 @@ public interface Queries { * * 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. + * The connection needs to be released by calling [SQLiteConnectionLease.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 @@ -202,5 +202,5 @@ public interface Queries { */ @ExperimentalPowerSyncAPI() @HiddenFromObjC() - public suspend fun leaseConnection(readOnly: Boolean = false): SQLiteConnection + public suspend fun leaseConnection(readOnly: Boolean = false): SQLiteConnectionLease } diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt new file mode 100644 index 00000000..cc3fff54 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt @@ -0,0 +1,113 @@ +package com.powersync.db.driver + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import com.powersync.DatabaseDriverFactory +import com.powersync.ExperimentalPowerSyncAPI +import com.powersync.openDatabase +import com.powersync.utils.JsonUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex + +@OptIn(ExperimentalPowerSyncAPI::class) +internal class InternalConnectionPool( + private val factory: DatabaseDriverFactory, + private val scope: CoroutineScope, + private val dbFilename: String, + private val dbDirectory: String?, + private val writeLockMutex: Mutex, + ): SQLiteConnectionPool { + + private val writeConnection = newConnection(false) + private val readPool = ReadPool({ newConnection(true) }, scope=scope) + + // MutableSharedFlow to emit batched table updates + private val tableUpdatesFlow = MutableSharedFlow>(replay = 0) + + private fun newConnection(readOnly: Boolean): SQLiteConnection { + val connection = openDatabase( + factory = factory, + dbFilename = dbFilename, + dbDirectory = dbDirectory, + readOnly = false, + ) + + connection.execSQL("pragma journal_mode = WAL") + connection.execSQL("pragma journal_size_limit = ${6 * 1024 * 1024}") + connection.execSQL("pragma busy_timeout = 30000") + connection.execSQL("pragma cache_size = ${50 * 1024}") + + if (readOnly) { + connection.execSQL("pragma query_only = TRUE") + } + + // Older versions of the SDK used to set up an empty schema and raise the user version to 1. + // Keep doing that for consistency. + if (!readOnly) { + val version = + connection.prepare("pragma user_version").use { + require(it.step()) + if (it.isNull(0)) 0L else it.getLong(0) + } + if (version < 1L) { + connection.execSQL("pragma user_version = 1") + } + + // Also install a commit, rollback and update hooks in the core extension to implement + // the updates flow here (not all our driver implementations support hooks, so this is + // a more reliable fallback). + connection.execSQL("select powersync_update_hooks('install');") + } + + return connection + } + + override suspend fun read(): SQLiteConnectionLease { + return readPool.obtainConnection() + } + + override suspend fun write(): SQLiteConnectionLease { + writeLockMutex.lock() + return RawConnectionLease(writeConnection) { + // When we've leased a write connection, we may have to update table update flows + // after users ran their custom statements. + writeConnection.prepare("SELECT powersync_update_hooks('get')").use { + check(it.step()) + val updatedTables = JsonUtil.json.decodeFromString>(it.getText(0)) + if (updatedTables.isNotEmpty()) { + scope.launch { + tableUpdatesFlow.emit(updatedTables) + } + } + } + + writeLockMutex.unlock() + } + } + + override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { + // First get a lock on all read connections + readPool.withAllConnections { rawReadConnections -> + val readers = rawReadConnections.map { RawConnectionLease(it) {} } + // Then get access to the write connection + val writer = write() + + try { + action(writer, readers) + } finally { + writer.close() + } + } + } + + override val updates: SharedFlow> + get() = tableUpdatesFlow + + override suspend fun close() { + writeConnection.close() + readPool.close() + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt new file mode 100644 index 00000000..d0b45fbc --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt @@ -0,0 +1,50 @@ +package com.powersync.db.driver + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import com.powersync.ExperimentalPowerSyncAPI + +/** + * A temporary view / lease of an inner [androidx.sqlite.SQLiteConnection] managed by the PowerSync + * SDK. + */ +@OptIn(ExperimentalPowerSyncAPI::class) +internal class RawConnectionLease( + private val connection: SQLiteConnection, + private val returnConnection: () -> Unit, +) : SQLiteConnectionLease { + private var isCompleted = false + + private fun checkNotCompleted() { + check(!isCompleted) { "Connection lease already closed" } + } + + override suspend fun isInTransaction(): Boolean { + return isInTransactionSync() + } + + override fun isInTransactionSync(): Boolean { + checkNotCompleted() + return connection.inTransaction() + } + + override suspend fun usePrepared( + sql: String, + block: (SQLiteStatement) -> R + ): R { + return usePreparedSync(sql, block) + } + + override fun usePreparedSync(sql: String, block: (SQLiteStatement) -> R): R { + checkNotCompleted() + return connection.prepare(sql).use(block) + } + + override suspend fun close() { + // Note: This is a lease, don't close the underlying connection. + if (!isCompleted) { + isCompleted = true + returnConnection() + } + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt similarity index 92% rename from core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt rename to core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt index c72f9a5b..095a3e28 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt @@ -1,6 +1,7 @@ -package com.powersync.db.internal +package com.powersync.db.driver import androidx.sqlite.SQLiteConnection +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -11,7 +12,11 @@ import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -internal class ConnectionPool( +/** + * The read-part of a [SQLiteConnectionPool] backed by connections owned by the PowerSync SDK. + */ +@OptIn(ExperimentalPowerSyncAPI::class) +internal class ReadPool( factory: () -> SQLiteConnection, size: Int = 5, private val scope: CoroutineScope, @@ -83,6 +88,7 @@ internal class ConnectionPool( available.cancel(PoolClosedException) connections.joinAll() } + } internal object PoolClosedException : CancellationException("Pool is closed") diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt new file mode 100644 index 00000000..1e32d1da --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt @@ -0,0 +1,58 @@ +package com.powersync.db.driver + +import androidx.sqlite.SQLiteStatement +import com.powersync.ExperimentalPowerSyncAPI +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.runBlocking + +@ExperimentalPowerSyncAPI() +public interface SQLiteConnectionPool { + public suspend fun read(): SQLiteConnectionLease + + public suspend fun withAllConnections(action: suspend ( + writer: SQLiteConnectionLease, + readers: List + ) -> R) + + public suspend fun write(): SQLiteConnectionLease + + public val updates: SharedFlow> + + public suspend fun close() +} + +@ExperimentalPowerSyncAPI +public interface SQLiteConnectionLease { + /** + * Queries the autocommit state on the connection. + */ + public suspend fun isInTransaction(): Boolean + + public fun isInTransactionSync(): Boolean { + return runBlocking { isInTransaction() } + } + + /** + * Prepares [sql] as statement and runs [block] with it. + * + * Block most only run on a single-thread. The statement must not be used once [block] returns. + */ + public suspend fun usePrepared(sql: String, block: (SQLiteStatement) -> R): R + + public fun usePreparedSync(sql: String, block: (SQLiteStatement) -> R): R { + return runBlocking { + usePrepared(sql, block) + } + } + + public suspend fun execSQL(sql: String) { + usePrepared(sql) { + it.step() + } + } + + /** + * Returns the leased connection to the pool. + */ + public suspend fun close() +} 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 5345bb47..6c70780b 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt @@ -1,12 +1,15 @@ package com.powersync.db.internal -import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteStatement +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException import com.powersync.db.SqlCursor import com.powersync.db.StatementBasedCursor +import com.powersync.db.driver.SQLiteConnectionLease public interface ConnectionContext { + // TODO (breaking): Make asynchronous, create shared superinterface with Queries + @Throws(PowerSyncException::class) public fun execute( sql: String, @@ -35,8 +38,9 @@ public interface ConnectionContext { ): RowType } +@ExperimentalPowerSyncAPI internal class ConnectionContextImplementation( - private val rawConnection: SQLiteConnection, + private val rawConnection: SQLiteConnectionLease, ) : ConnectionContext { override fun execute( sql: String, @@ -46,9 +50,11 @@ internal class ConnectionContextImplementation( while (it.step()) { // Iterate through the statement } + } - // TODO: What is this even supposed to return - return 0L + return withStatement("SELECT changes()", null) { + check(it.step()) + it.getLong(0) } } @@ -88,36 +94,32 @@ internal class ConnectionContextImplementation( private inline fun withStatement( sql: String, parameters: List?, - block: (SQLiteStatement) -> T, - ): T = prepareStmt(sql, parameters).use(block) + crossinline block: (SQLiteStatement) -> T, + ): T { + return rawConnection.usePreparedSync(sql) { stmt -> + stmt.bind(parameters) + block(stmt) + } + } +} - private fun prepareStmt( - sql: String, - parameters: List?, - ): SQLiteStatement = - rawConnection.prepare(sql).apply { - try { - parameters?.forEachIndexed { i, parameter -> - // SQLite parameters are 1-indexed - val index = i + 1 +internal fun SQLiteStatement.bind(parameters: List?) { + 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") - } - } - } + 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/InternalDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt index fdcce6fd..cd6538b4 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -1,19 +1,17 @@ package com.powersync.db.internal 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 import com.powersync.db.ThrowableTransactionCallback +import com.powersync.db.driver.SQLiteConnectionLease +import com.powersync.db.driver.SQLiteConnectionPool import com.powersync.db.runWrapped import com.powersync.utils.AtomicMutableSet import com.powersync.utils.JsonUtil import com.powersync.utils.throttle -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -22,66 +20,16 @@ import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.flow.transform -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.milliseconds +@OptIn(ExperimentalPowerSyncAPI::class) 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, + private val pool: SQLiteConnectionPool ) : InternalDatabase { - private val updates = UpdateFlow(logger) - - private val writeConnection = newConnection(false) - - private val readPool = - ConnectionPool(factory = { - newConnection(true) - }, scope = scope) - // Could be scope.coroutineContext, but the default is GlobalScope, which seems like a bad idea. To discuss. private val dbContext = Dispatchers.IO - private fun newConnection(readOnly: Boolean): SQLiteConnection { - val connection = - factory.openDatabase( - dbFilename = dbFilename, - dbDirectory = dbDirectory, - readOnly = false, - // We don't need a listener on read-only connections since we don't expect any update - // hooks here. - listener = if (readOnly) null else updates, - ) - - connection.execSQL("pragma journal_mode = WAL") - connection.execSQL("pragma journal_size_limit = ${6 * 1024 * 1024}") - connection.execSQL("pragma busy_timeout = 30000") - connection.execSQL("pragma cache_size = ${50 * 1024}") - - if (readOnly) { - connection.execSQL("pragma query_only = TRUE") - } - - // Older versions of the SDK used to set up an empty schema and raise the user version to 1. - // Keep doing that for consistency. - if (!readOnly) { - val version = - connection.prepare("pragma user_version").use { - require(it.step()) - if (it.isNull(0)) 0L else it.getLong(0) - } - if (version < 1L) { - connection.execSQL("pragma user_version = 1") - } - } - - return connection - } override suspend fun execute( sql: String, @@ -94,9 +42,7 @@ internal class InternalDatabaseImpl( override suspend fun updateSchema(schemaJson: String) { withContext(dbContext) { runWrapped { - // First get a lock on all read connections - readPool.withAllConnections { readConnections -> - // Then get access to the write connection + pool.withAllConnections { writer, readers -> writeTransaction { tx -> tx.getOptional( "SELECT powersync_replace_schema(?);", @@ -105,9 +51,8 @@ internal class InternalDatabaseImpl( } // Update the schema on all read connections - for (readConnection in readConnections) { - ConnectionContextImplementation(readConnection) - .getAll("pragma table_info('sqlite_master')") {} + for (readConnection in readers) { + readConnection.execSQL("pragma table_info('sqlite_master')") } } } @@ -207,35 +152,20 @@ internal class InternalDatabaseImpl( } } - @ExperimentalPowerSyncAPI - override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnection = - if (readOnly) { - readPool.obtainConnection() - } else { - writeLockMutex.lock() - RawConnectionLease(writeConnection) { - scope.launch { - // When we've leased a write connection, we may have to update table update - // flows after users ran their custom statements. - // For internal queries, this happens with leaseWrite() and an asynchronous call - // in internalWriteLock - updates.fireTableUpdates() - } - writeLockMutex.unlock() - } + override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnectionLease { + return if (readOnly) { + pool.read() + } else { + pool.write() } - - private suspend fun leaseWrite(): SQLiteConnection { - writeLockMutex.lock() - return 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 = + private suspend fun internalReadLock(callback: suspend (SQLiteConnectionLease) -> R): R = withContext(dbContext) { runWrapped { val connection = leaseConnection(readOnly = true) @@ -265,18 +195,14 @@ internal class InternalDatabaseImpl( } @OptIn(ExperimentalPowerSyncAPI::class) - private suspend fun internalWriteLock(callback: (SQLiteConnection) -> R): R = + private suspend fun internalWriteLock(callback: suspend (SQLiteConnectionLease) -> R): R = withContext(dbContext) { - val lease = leaseWrite() + val lease = pool.write() try { runWrapped { catchSwiftExceptions { callback(lease) } - }.also { - // Trigger watched queries - // Fire updates inside the write lock - updates.fireTableUpdates() } } finally { // Returning the lease will unlock the writeLockMutex @@ -291,6 +217,7 @@ internal class InternalDatabaseImpl( override suspend fun writeTransaction(callback: ThrowableTransactionCallback): R = internalWriteLock { + it.runTransaction { tx -> // Need to catch Swift exceptions here for Rollback catchSwiftExceptions { @@ -300,7 +227,7 @@ internal class InternalDatabaseImpl( } // Register callback for table updates on a specific table - override fun updatesOnTables(): SharedFlow> = updates.updatesOnTables() + override fun updatesOnTables(): SharedFlow> = pool.updates // Unfortunately Errors can't be thrown from Swift SDK callbacks. // These are currently returned and should be thrown here. @@ -351,8 +278,7 @@ internal class InternalDatabaseImpl( override suspend fun close() { runWrapped { - writeConnection.close() - readPool.close() + pool.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 7485e8ef..1b0fcac5 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt @@ -1,20 +1,21 @@ package com.powersync.db.internal -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.execSQL +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException import com.powersync.db.SqlCursor +import com.powersync.db.driver.SQLiteConnectionLease public interface PowerSyncTransaction : ConnectionContext +@ExperimentalPowerSyncAPI internal class PowerSyncTransactionImpl( - private val rawConnection: SQLiteConnection, + private val lease: SQLiteConnectionLease, ) : PowerSyncTransaction, ConnectionContext { - private val delegate = ConnectionContextImplementation(rawConnection) + private val delegate = ConnectionContextImplementation(lease) private fun checkInTransaction() { - if (!rawConnection.inTransaction()) { + if (!lease.isInTransactionSync()) { throw PowerSyncException("Tried executing statement on a transaction that has been rolled back", cause = null) } } @@ -55,18 +56,19 @@ internal class PowerSyncTransactionImpl( } } -internal inline fun SQLiteConnection.runTransaction(cb: (PowerSyncTransaction) -> T): T { +@ExperimentalPowerSyncAPI +internal suspend fun SQLiteConnectionLease.runTransaction(cb: suspend (PowerSyncTransaction) -> T): T { execSQL("BEGIN") var didComplete = false return try { val result = cb(PowerSyncTransactionImpl(this)) didComplete = true - check(inTransaction()) + check(isInTransaction()) execSQL("COMMIT") result } catch (e: Throwable) { - if (!didComplete && inTransaction()) { + if (!didComplete && isInTransaction()) { execSQL("ROLLBACK") } diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt deleted file mode 100644 index f020de45..00000000 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.powersync.db.internal - -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.SQLiteStatement - -/** - * A temporary view / lease of an inner [SQLiteConnection] managed by the PowerSync SDK. - */ -internal class RawConnectionLease( - private val connection: SQLiteConnection, - private val returnConnection: () -> Unit, -) : SQLiteConnection { - private var isCompleted = false - - private fun checkNotCompleted() { - check(!isCompleted) { "Connection lease already closed" } - } - - override fun inTransaction(): Boolean { - checkNotCompleted() - return connection.inTransaction() - } - - override fun prepare(sql: String): SQLiteStatement { - checkNotCompleted() - return connection.prepare(sql) - } - - override fun close() { - // Note: This is a lease, don't close the underlying connection. - if (!isCompleted) { - isCompleted = true - returnConnection() - } - } -} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt deleted file mode 100644 index c7adab10..00000000 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/UpdateFlow.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.powersync.db.internal - -import co.touchlab.kermit.Logger -import com.powersync.internal.driver.ConnectionListener -import com.powersync.utils.AtomicMutableSet -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -internal class UpdateFlow( - private val logger: Logger, -) : ConnectionListener { - // MutableSharedFlow to emit batched table updates - private val tableUpdatesFlow = MutableSharedFlow>(replay = 0) - - // In-memory buffer to store table names before flushing - private val pendingUpdates = AtomicMutableSet() - - override fun onCommit() {} - - override fun onRollback() { - logger.v { "onRollback, clearing pending updates" } - pendingUpdates.clear() - } - - override fun onUpdate( - kind: Int, - database: String, - table: String, - rowid: Long, - ) { - pendingUpdates.add(table) - } - - // Flows on any table change - // This specifically returns a SharedFlow for downstream timing considerations - fun updatesOnTables(): SharedFlow> = - tableUpdatesFlow - .asSharedFlow() - - suspend fun fireTableUpdates() { - val updates = pendingUpdates.toSetAndClear() - if (updates.isNotEmpty()) { - logger.v { "Firing table updates for $updates" } - } - - tableUpdatesFlow.emit(updates) - } -} diff --git a/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt b/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt deleted file mode 100644 index 6071efe6..00000000 --- a/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.powersync - -import com.powersync.internal.driver.NativeConnection - -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 7a3efba2..aa050d21 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -1,33 +1,15 @@ package com.powersync -import androidx.sqlite.SQLiteConnection -import com.powersync.db.loadExtensions -import com.powersync.internal.driver.ConnectionListener -import com.powersync.internal.driver.JdbcConnection -import com.powersync.internal.driver.JdbcDriver +import androidx.sqlite.driver.bundled.BundledSQLiteDriver @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") public actual class DatabaseDriverFactory { - internal actual fun openDatabase( - dbFilename: String, - dbDirectory: String?, - readOnly: Boolean, - listener: ConnectionListener?, - ): SQLiteConnection { - val dbPath = - if (dbDirectory != null) { - "$dbDirectory/$dbFilename" - } else { - dbFilename - } - - val driver = JdbcDriver() - val connection = driver.openDatabase(dbPath, readOnly, listener) as JdbcConnection - connection.loadExtensions( - powersyncExtension to "sqlite3_powersync_init", - ) + internal actual fun addPowerSyncExtension(driver: BundledSQLiteDriver) { + driver.addExtension(powersyncExtension, "sqlite3_powersync_init") + } - return connection + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { + return dbFilename } public companion object { diff --git a/core/src/macosMain/kotlin/com/powersync/DatabaseDriverFactory.macos.kt b/core/src/macosMain/kotlin/com/powersync/DatabaseDriverFactory.macos.kt deleted file mode 100644 index 6071efe6..00000000 --- a/core/src/macosMain/kotlin/com/powersync/DatabaseDriverFactory.macos.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.powersync - -import com.powersync.internal.driver.NativeConnection - -internal actual fun NativeConnection.loadPowerSyncSqliteCoreExtension() { - loadPowerSyncSqliteCoreExtensionDynamically() -} diff --git a/core/src/tvosMain/kotlin/com/powersync/DatabaseDriverFactory.tvos.kt b/core/src/tvosMain/kotlin/com/powersync/DatabaseDriverFactory.tvos.kt deleted file mode 100644 index 2f2c759c..00000000 --- a/core/src/tvosMain/kotlin/com/powersync/DatabaseDriverFactory.tvos.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.powersync - -import co.touchlab.sqliter.DatabaseConnection - -internal actual fun DatabaseConnection.loadPowerSyncSqliteCoreExtension() { - loadPowerSyncSqliteCoreExtensionDynamically() -} diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index cc7747a8..2121c72c 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -1,17 +1,32 @@ package com.powersync -import com.powersync.internal.driver.NativeConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.powersync.static.powersync_init_static -internal actual fun NativeConnection.loadPowerSyncSqliteCoreExtension() { - val rc = powersync_init_static() - if (rc != 0) { - throw PowerSyncException( - "Could not load the PowerSync SQLite core extension", - cause = - Exception( - "Calling powersync_init_static returned result code $rc", - ), - ) +@Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) +public actual class DatabaseDriverFactory { + internal actual fun addPowerSyncExtension(driver: BundledSQLiteDriver) { + didLoadExtension + } + + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { + return appleDefaultDatabasePath(dbFilename) + } + + private companion object { + val didLoadExtension by lazy { + val rc = powersync_init_static() + if (rc != 0) { + throw PowerSyncException( + "Could not load the PowerSync SQLite core extension", + cause = + Exception( + "Calling powersync_init_static returned result code $rc", + ), + ) + } + + true + } } } diff --git a/demos/android-supabase-todolist/build.gradle.kts b/demos/android-supabase-todolist/build.gradle.kts index a94a9b38..66eea107 100644 --- a/demos/android-supabase-todolist/build.gradle.kts +++ b/demos/android-supabase-todolist/build.gradle.kts @@ -4,7 +4,6 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) - alias(libs.plugins.sqldelight) alias(libs.plugins.compose.compiler) } diff --git a/drivers/README.md b/drivers/README.md deleted file mode 100644 index d1d8f3f9..00000000 --- a/drivers/README.md +++ /dev/null @@ -1,3 +0,0 @@ -Internal drivers for SQLite. - -These projects are currently internal to the PowerSync SDK and should not be depended on directly. diff --git a/drivers/common/build.gradle.kts b/drivers/common/build.gradle.kts deleted file mode 100644 index 1c55c497..00000000 --- a/drivers/common/build.gradle.kts +++ /dev/null @@ -1,62 +0,0 @@ -import com.powersync.plugins.utils.powersyncTargets - -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidLibrary) - alias(libs.plugins.kotlinter) - id("com.powersync.plugins.sonatype") -} - -kotlin { - powersyncTargets() - explicitApi() - applyDefaultHierarchyTemplate() - - sourceSets { - commonMain.dependencies { - api(libs.androidx.sqlite) - } - - val commonJava by creating { - dependsOn(commonMain.get()) - dependencies { - implementation(libs.sqlite.jdbc) - } - } - - jvmMain { - dependsOn(commonJava) - } - - androidMain { - dependsOn(commonJava) - } - - nativeMain.dependencies { - implementation(libs.androidx.sqliteFramework) - } - - all { - languageSettings { - optIn("kotlinx.cinterop.ExperimentalForeignApi") - } - } - } -} - -android { - namespace = "com.powersync.drivers.common" - compileSdk = - libs.versions.android.compileSdk - .get() - .toInt() - defaultConfig { - minSdk = - libs.versions.android.minSdk - .get() - .toInt() - } - kotlin { - jvmToolchain(17) - } -} diff --git a/drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt b/drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt deleted file mode 100644 index 44bd6609..00000000 --- a/drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 42206221..00000000 --- a/drivers/common/src/commonJava/kotlin/com/powersync/internal/driver/JdbcDriver.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.powersync.internal.driver - -import androidx.sqlite.SQLITE_DATA_NULL -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.SQLiteStatement -import org.sqlite.SQLiteCommitListener -import org.sqlite.SQLiteConfig -import org.sqlite.SQLiteOpenMode -import org.sqlite.SQLiteUpdateListener -import org.sqlite.jdbc4.JDBC4Connection -import org.sqlite.jdbc4.JDBC4PreparedStatement -import org.sqlite.jdbc4.JDBC4ResultSet -import java.sql.Types -import java.util.Properties - -public open class JdbcDriver : PowerSyncDriver { - internal open fun addDefaultProperties(properties: Properties) {} - - override fun openDatabase( - path: String, - readOnly: Boolean, - listener: ConnectionListener?, - ): SQLiteConnection { - val properties = - Properties().also { - it.setProperty( - SQLiteConfig.Pragma.OPEN_MODE.pragmaName, - if (readOnly) { - SQLiteOpenMode.READONLY.flag - } else { - SQLiteOpenMode.READWRITE.flag or SQLiteOpenMode.CREATE.flag - }.toString(), - ) - } - - val inner = JDBC4Connection(path, path, properties) - listener?.let { - inner.addCommitListener( - object : SQLiteCommitListener { - override fun onCommit() { - it.onCommit() - } - - override fun onRollback() { - it.onRollback() - } - }, - ) - - inner.addUpdateListener { type, database, table, rowId -> - val flags = - when (type) { - SQLiteUpdateListener.Type.INSERT -> SQLITE_INSERT - SQLiteUpdateListener.Type.DELETE -> SQLITE_DELETE - SQLiteUpdateListener.Type.UPDATE -> SQLITE_UPDATE - } - - it.onUpdate(flags, database, table, rowId) - } - } - - return JdbcConnection(inner) - } - - private companion object { - const val SQLITE_DELETE: Int = 9 - const val SQLITE_INSERT: Int = 18 - const val SQLITE_UPDATE: Int = 23 - } -} - -public class JdbcConnection( - public val connection: org.sqlite.SQLiteConnection, -) : SQLiteConnection { - override fun inTransaction(): Boolean { - // TODO: Unsupported with sqlite-jdbc? - return true - } - - override fun prepare(sql: String): SQLiteStatement = PowerSyncStatement(connection.prepareStatement(sql) as JDBC4PreparedStatement) - - override fun close() { - connection.close() - } -} - -private class PowerSyncStatement( - private val stmt: JDBC4PreparedStatement, -) : SQLiteStatement { - private var currentCursor: JDBC4ResultSet? = null - - private val _columnCount: Int by lazy { - // We have to call this manually because stmt.metadata.columnCount throws an exception when - // a statement has zero columns. - stmt.pointer.safeRunInt { db, ptr -> db.column_count(ptr) } - } - - private fun requireCursor(): JDBC4ResultSet = - requireNotNull(currentCursor) { - "Illegal call which requires cursor, step() hasn't been called" - } - - override fun bindBlob( - index: Int, - value: ByteArray, - ) { - stmt.setBytes(index, value) - } - - override fun bindDouble( - index: Int, - value: Double, - ) { - stmt.setDouble(index, value) - } - - override fun bindLong( - index: Int, - value: Long, - ) { - stmt.setLong(index, value) - } - - override fun bindText( - index: Int, - value: String, - ) { - stmt.setString(index, value) - } - - override fun bindNull(index: Int) { - stmt.setNull(index, Types.NULL) - } - - override fun getBlob(index: Int): ByteArray = requireCursor().getBytes(index + 1) - - override fun getDouble(index: Int): Double = requireCursor().getDouble(index + 1) - - override fun getLong(index: Int): Long = requireCursor().getLong(index + 1) - - override fun getText(index: Int): String = requireCursor().getString(index + 1) - - override fun isNull(index: Int): Boolean = getColumnType(index) == SQLITE_DATA_NULL - - override fun getColumnCount(): Int = _columnCount - - override fun getColumnName(index: Int): String = stmt.metaData.getColumnName(index + 1) - - override fun getColumnType(index: Int): Int = stmt.pointer.safeRunInt { db, ptr -> db.column_type(ptr, index) } - - override fun step(): Boolean { - if (currentCursor == null) { - if (_columnCount == 0) { - // sqlite-jdbc refuses executeQuery calls for statements that don't return results - stmt.execute() - return false - } else { - currentCursor = stmt.executeQuery() as JDBC4ResultSet - } - } - - return currentCursor!!.next() - } - - override fun reset() { - currentCursor?.close() - currentCursor = null - } - - override fun clearBindings() { - stmt.clearParameters() - } - - override fun close() { - currentCursor?.close() - currentCursor = null - stmt.close() - } -} diff --git a/drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt b/drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt deleted file mode 100644 index 4baa7535..00000000 --- a/drivers/common/src/commonMain/kotlin/com/powersync/internal/driver/PowerSyncDriver.kt +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 581d5e8f..00000000 --- a/drivers/common/src/nativeMain/kotlin/com/powersync/internal/driver/NativeDriver.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.powersync.internal.driver - -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.SQLiteStatement -import androidx.sqlite.driver.NativeSQLiteConnection -import androidx.sqlite.throwSQLiteException -import cnames.structs.sqlite3 -import kotlinx.cinterop.ByteVar -import kotlinx.cinterop.COpaquePointer -import kotlinx.cinterop.CPointer -import kotlinx.cinterop.StableRef -import kotlinx.cinterop.allocPointerTo -import kotlinx.cinterop.asStableRef -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.ptr -import kotlinx.cinterop.staticCFunction -import kotlinx.cinterop.toKString -import kotlinx.cinterop.value -import sqlite3.SQLITE_OPEN_CREATE -import sqlite3.SQLITE_OPEN_READONLY -import sqlite3.SQLITE_OPEN_READWRITE -import sqlite3.sqlite3_commit_hook -import sqlite3.sqlite3_open_v2 -import sqlite3.sqlite3_rollback_hook -import sqlite3.sqlite3_update_hook - -public class NativeDriver : PowerSyncDriver { - override fun openDatabase( - path: String, - readOnly: Boolean, - listener: ConnectionListener?, - ): SQLiteConnection = openNativeDatabase(path, readOnly, listener) - - public fun openNativeDatabase( - path: String, - readOnly: Boolean, - listener: ConnectionListener?, - ): NativeConnection { - val flags = - if (readOnly) { - SQLITE_OPEN_READONLY - } else { - SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE - } - - return memScoped { - val dbPointer = allocPointerTo() - val resultCode = - sqlite3_open_v2(filename = path, ppDb = dbPointer.ptr, flags = flags, zVfs = null) - - if (resultCode != 0) { - throwSQLiteException(resultCode, null) - } - - NativeConnection(dbPointer.value!!, listener) - } - } -} - -public class NativeConnection( - public val sqlite: CPointer, - listener: ConnectionListener?, -) : SQLiteConnection { - private val inner: NativeSQLiteConnection = NativeSQLiteConnection(sqlite) - private val listener: StableRef? = - listener?.let { StableRef.create(it) }?.also { - sqlite3_update_hook(sqlite, updateHook, it.asCPointer()) - sqlite3_commit_hook(sqlite, commitHook, it.asCPointer()) - sqlite3_rollback_hook(sqlite, rollbackHook, it.asCPointer()) - } - - override fun inTransaction(): Boolean = inner.inTransaction() - - override fun prepare(sql: String): SQLiteStatement = inner.prepare(sql) - - override fun close() { - inner.close() - listener?.dispose() - } -} - -private val commitHook = - staticCFunction { - val listener = it!!.asStableRef().get() - listener.onCommit() - 0 - } - -private val rollbackHook = - staticCFunction { - val listener = it!!.asStableRef().get() - listener.onRollback() - } - -private val updateHook = - staticCFunction< - COpaquePointer?, - Int, - CPointer?, - CPointer?, - Long, - Unit, - > { ctx, type, db, table, rowId -> - val listener = ctx!!.asStableRef().get() - listener.onUpdate( - type, - db!!.toKString(), - table!!.toKString(), - rowId, - ) - } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a67168b8..5746d644 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,6 @@ kotlinx-io = "0.8.0" ktor = "3.2.3" uuid = "0.8.4" powersync-core = "0.4.4" -sqlite-jdbc = "3.50.3.0" turbine = "1.2.1" kotest = "5.9.1" @@ -92,14 +91,12 @@ ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } -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" } +androidx-sqlite-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "androidxSqlite" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidxSqlite" } # Sample - Android androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt deleted file mode 100644 index e51df1bc..00000000 --- a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.powersync.compile - -import kotlin.io.path.Path -import org.gradle.api.DefaultTask -import org.gradle.api.GradleException -import org.gradle.api.provider.Provider -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.Property -import org.gradle.api.provider.ProviderFactory -import org.gradle.api.tasks.CacheableTask -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import org.jetbrains.kotlin.konan.target.KonanTarget -import javax.inject.Inject -import kotlin.io.path.absolutePathString -import kotlin.io.path.name - -@CacheableTask -abstract class ClangCompile: DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val inputFile: RegularFileProperty - - @get:Input - abstract val konanTarget: Property - - @get:InputDirectory - @get:PathSensitive(PathSensitivity.NONE) - abstract val include: DirectoryProperty - - @get:OutputFile - abstract val objectFile: RegularFileProperty - - @get:Inject - protected abstract val providers: ProviderFactory - - @get:Input - val xcodeInstallation: Provider get() = providers.exec { - executable("xcode-select") - args("-p") - }.standardOutput.asText - - @TaskAction - fun run() { - val target = requireNotNull(KonanTarget.predefinedTargets[konanTarget.get()]) - val xcodePath = xcodeInstallation.get().trim() - if (xcodePath.isEmpty()) { - throw GradleException("xcode-select was unable to resolve an XCode installation") - } - - val xcode = Path(xcodePath) - val toolchain = xcode.resolve("Toolchains/XcodeDefault.xctoolchain/usr/bin").absolutePathString() - - val (llvmTarget, sysRoot) = when (target) { - KonanTarget.IOS_X64 -> "x86_64-apple-ios12.0-simulator" to IOS_SIMULATOR_SDK - KonanTarget.IOS_ARM64 -> "arm64-apple-ios12.0" to IOS_SDK - KonanTarget.IOS_SIMULATOR_ARM64 -> "arm64-apple-ios14.0-simulator" to IOS_SIMULATOR_SDK - KonanTarget.MACOS_ARM64 -> "aarch64-apple-macos" to MACOS_SDK - KonanTarget.MACOS_X64 -> "x86_64-apple-macos" to MACOS_SDK - KonanTarget.WATCHOS_DEVICE_ARM64 -> "aarch64-apple-watchos" to WATCHOS_SDK - KonanTarget.WATCHOS_ARM32 -> "armv7k-apple-watchos" to WATCHOS_SDK - KonanTarget.WATCHOS_ARM64 -> "arm64_32-apple-watchos" to WATCHOS_SDK - KonanTarget.WATCHOS_SIMULATOR_ARM64 -> "aarch64-apple-watchos-simulator" to WATCHOS_SIMULATOR_SDK - KonanTarget.WATCHOS_X64 -> "x86_64-apple-watchos-simulator" to WATCHOS_SIMULATOR_SDK - KonanTarget.TVOS_ARM64 -> "aarch64-apple-tvos" to TVOS_SDK - KonanTarget.TVOS_X64 -> "x86_64-apple-tvos-simulator" to TVOS_SIMULATOR_SDK - KonanTarget.TVOS_SIMULATOR_ARM64 -> "aarch64-apple-tvos-simulator" to TVOS_SIMULATOR_SDK - else -> error("Unexpected target $target") - } - - val output = objectFile.get() - - providers.exec { - executable = "clang" - args( - "-B${toolchain}", - "-fno-stack-protector", - "-target", - llvmTarget, - "-isysroot", - xcode.resolve(sysRoot).absolutePathString(), - "-fPIC", - "--compile", - "-I${include.get().asFile.absolutePath}", - inputFile.get().asFile.absolutePath, - "-DHAVE_GETHOSTUUID=0", - "-DSQLITE_ENABLE_DBSTAT_VTAB", - "-DSQLITE_ENABLE_FTS5", - "-DSQLITE_ENABLE_RTREE", - "-O3", - "-o", - output.asFile.toPath().name, - ) - - workingDir = output.asFile.parentFile - }.result.get() - } - - companion object { - const val WATCHOS_SDK = "Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk" - const val WATCHOS_SIMULATOR_SDK = "Platforms/WatchSimulator.platform/Developer/SDKs/WatchSimulator.sdk/" - const val IOS_SDK = "Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk" - const val IOS_SIMULATOR_SDK = "Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk" - const val TVOS_SDK = "Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS.sdk" - const val TVOS_SIMULATOR_SDK = "Platforms/AppleTVSimulator.platform/Developer/SDKs/AppleTVSimulator.sdk" - const val MACOS_SDK = "Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/" - } -} diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateSqliteCInterop.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateSqliteCInterop.kt deleted file mode 100644 index fd93a4ad..00000000 --- a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateSqliteCInterop.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.powersync.compile - -import org.gradle.api.DefaultTask -import org.gradle.api.file.ProjectLayout -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.TaskAction -import org.gradle.work.DisableCachingByDefault -import javax.inject.Inject - -@DisableCachingByDefault(because = "not worth caching") -abstract class CreateSqliteCInterop: DefaultTask() { - @get:InputFile - abstract val archiveFile: RegularFileProperty - - @get:OutputFile - abstract val definitionFile: RegularFileProperty - - @get:Inject - abstract val layout: ProjectLayout - - @TaskAction - fun run() { - val archive = archiveFile.get().asFile - val parent = archive.parentFile - - definitionFile.get().asFile.writeText(""" - package = com.powersync.sqlite3 - - linkerOpts.linux_x64 = -lpthread -ldl - linkerOpts.macos_x64 = -lpthread -ldl - staticLibraries=${archive.name} - libraryPaths=${parent.relativeTo(layout.projectDirectory.asFile.canonicalFile)} - """.trimIndent(), - ) - } -} diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateStaticLibrary.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateStaticLibrary.kt deleted file mode 100644 index 58dfdba6..00000000 --- a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateStaticLibrary.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.powersync.compile - -import org.gradle.api.DefaultTask -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.ProviderFactory -import org.gradle.api.tasks.CacheableTask -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import javax.inject.Inject - -@CacheableTask -abstract class CreateStaticLibrary: DefaultTask() { - @get:InputFiles - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val objects: ConfigurableFileCollection - - @get:OutputFile - abstract val staticLibrary: RegularFileProperty - - @get:Inject - abstract val providers: ProviderFactory - - @TaskAction - fun run() { - providers.exec { - executable = "ar" - args("rc", staticLibrary.get().asFile.absolutePath) - for (file in objects.files) { - args(file.absolutePath) - } - }.result.get() - } -} diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/UnzipSqlite.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/UnzipSqlite.kt deleted file mode 100644 index 65aa1157..00000000 --- a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/UnzipSqlite.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.powersync.compile - -import org.gradle.api.file.Directory -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.FileTree -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.CacheableTask -import org.gradle.api.tasks.Copy -import org.gradle.api.tasks.OutputDirectory - -/** - * A cacheable [Copy] task providing a typed provider for the output directory. - */ -@CacheableTask -abstract class UnzipSqlite: Copy() { - @get:OutputDirectory - abstract val destination: DirectoryProperty - - fun unzipSqlite(src: FileTree, dir: Provider) { - from( - src.matching { - include("*/sqlite3.*") - exclude { - it.isDirectory - } - eachFile { - this.path = this.name - } - }, - ) - - into(dir) - destination.set(dir) - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3d4c4c4c..77b4160c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,12 +30,9 @@ rootProject.name = "powersync-root" include(":core") include(":core-tests-android") include(":connectors:supabase") -include("static-sqlite-driver") include(":PowerSyncKotlin") -include(":drivers:common") - include(":compose") include(":demos:android-supabase-todolist") diff --git a/static-sqlite-driver/README.md b/static-sqlite-driver/README.md deleted file mode 100644 index 15ce2f7a..00000000 --- a/static-sqlite-driver/README.md +++ /dev/null @@ -1 +0,0 @@ -This project builds a `.klib` linking sqlite3 statically, without containing other Kotlin sources. diff --git a/static-sqlite-driver/build.gradle.kts b/static-sqlite-driver/build.gradle.kts deleted file mode 100644 index f4afafd1..00000000 --- a/static-sqlite-driver/build.gradle.kts +++ /dev/null @@ -1,113 +0,0 @@ -import com.powersync.compile.ClangCompile -import com.powersync.compile.CreateSqliteCInterop -import com.powersync.compile.CreateStaticLibrary -import com.powersync.compile.UnzipSqlite -import java.io.File -import com.powersync.plugins.sonatype.setupGithubRepository -import com.powersync.plugins.utils.powersyncTargets -import de.undercouch.gradle.tasks.download.Download -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget -import org.jetbrains.kotlin.konan.target.HostManager - -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.downloadPlugin) - alias(libs.plugins.kotlinter) - id("com.powersync.plugins.sonatype") -} - -val sqliteVersion = "3500300" -val sqliteReleaseYear = "2025" - -val downloadSQLiteSources by tasks.registering(Download::class) { - val zipFileName = "sqlite-amalgamation-$sqliteVersion.zip" - src("https://www.sqlite.org/$sqliteReleaseYear/$zipFileName") - dest(layout.buildDirectory.dir("downloads").map { it.file(zipFileName) }) - onlyIfNewer(true) - overwrite(false) -} - -val unzipSQLiteSources by tasks.registering(UnzipSqlite::class) { - val zip = downloadSQLiteSources.map { it.outputs.files.singleFile } - inputs.file(zip) - - unzipSqlite( - src = zipTree(zip), - dir = layout.buildDirectory.dir("downloads/sqlite3") - ) -} - -// Obtain host and platform manager from Kotlin multiplatform plugin. They're supposed to be -// internal, but it's very convenient to have them because they expose the necessary toolchains we -// use to compile SQLite for the platforms we need. -val hostManager = HostManager() - -fun compileSqlite(target: KotlinNativeTarget): TaskProvider { - val name = target.targetName - val outputDir = layout.buildDirectory.dir("c/$name") - - val sqlite3Obj = outputDir.map { it.file("sqlite3.o") } - val archive = outputDir.map { it.file("libsqlite3.a") } - - val compileSqlite = tasks.register("${name}CompileSqlite", ClangCompile::class) { - inputs.dir(unzipSQLiteSources.map { it.destination }) - - inputFile.set(unzipSQLiteSources.flatMap { it.destination.file("sqlite3.c") }) - konanTarget.set(target.konanTarget.name) - include.set(unzipSQLiteSources.flatMap { it.destination }) - objectFile.set(sqlite3Obj) - } - - val createStaticLibrary = tasks.register("${name}ArchiveSqlite", CreateStaticLibrary::class) { - inputs.file(compileSqlite.map { it.objectFile }) - objects.from(sqlite3Obj) - staticLibrary.set(archive) - } - - val buildCInteropDef = tasks.register("${name}CinteropSqlite", CreateSqliteCInterop::class) { - inputs.file(createStaticLibrary.map { it.staticLibrary }) - - archiveFile.set(archive) - definitionFile.fileProvider(archive.map { File(it.asFile.parentFile, "sqlite3.def") }) - } - - return buildCInteropDef -} - -kotlin { - // We use sqlite3-jdbc on JVM platforms instead - powersyncTargets(jvm=false) - - applyDefaultHierarchyTemplate() - explicitApi() - - sourceSets { - all { - languageSettings.apply { - optIn("kotlin.experimental.ExperimentalNativeApi") - optIn("kotlinx.cinterop.ExperimentalForeignApi") - optIn("kotlinx.cinterop.BetaInteropApi") - } - } - - nativeTest { - dependencies { - implementation(projects.drivers.common) - } - } - } - - targets.withType { - if (hostManager.isEnabled(konanTarget)) { - val compileSqlite3 = compileSqlite(this) - - compilations.named("main") { - cinterops.create("sqlite3") { - definitionFile.set(compileSqlite3.flatMap { it.definitionFile }) - } - } - } - } -} - -setupGithubRepository() diff --git a/static-sqlite-driver/gradle.properties b/static-sqlite-driver/gradle.properties deleted file mode 100644 index d648100b..00000000 --- a/static-sqlite-driver/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_ARTIFACT_ID=static-sqlite-driver -POM_NAME=Statically linked SQLite -POM_DESCRIPTION=A Kotlin-multiplatform bundle containing a static library for SQLite without Kotlin code. diff --git a/static-sqlite-driver/src/commonMain/kotlin/com/powersync/sqlite3/StaticSqliteDriver.kt b/static-sqlite-driver/src/commonMain/kotlin/com/powersync/sqlite3/StaticSqliteDriver.kt deleted file mode 100644 index 51f02006..00000000 --- a/static-sqlite-driver/src/commonMain/kotlin/com/powersync/sqlite3/StaticSqliteDriver.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.powersync.sqlite3 - -/** - * An empty Kotlin object. - * - * This package needs to provide a source to be published correctly. The only purpose of this package is to provide - * build scripts linking SQLite statically however, so this empty object is defined for publishing only. - */ -public object StaticSqliteDriver diff --git a/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt b/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt deleted file mode 100644 index 9967968e..00000000 --- a/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt +++ /dev/null @@ -1,15 +0,0 @@ -import com.powersync.internal.driver.NativeDriver -import kotlin.test.Test -import kotlin.test.assertEquals - -class SmokeTest { - @Test - fun canUseSqlite() { - val db = NativeDriver().openDatabase(":memory:") - db.prepare("SELECT sqlite_version();").use { stmt -> - assertEquals(true, stmt.step()) - } - - db.close() - } -} From f532ba9cda2f77b86350d6359e90c9ea88553bdb Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 22 Aug 2025 11:58:38 +0200 Subject: [PATCH 08/28] Fix deadlock in initialization --- .../kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 cd6538b4..a268bd56 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -43,7 +43,7 @@ internal class InternalDatabaseImpl( withContext(dbContext) { runWrapped { pool.withAllConnections { writer, readers -> - writeTransaction { tx -> + writer.runTransaction { tx -> tx.getOptional( "SELECT powersync_replace_schema(?);", listOf(schemaJson), From c7adbab08af5fc7a69b2049317943b6880c21343 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 22 Aug 2025 13:48:28 +0200 Subject: [PATCH 09/28] Make addPowerSyncExtension public --- .../DatabaseDriverFactory.android.kt | 8 ++--- .../DatabaseDriverFactory.appleNonWatchOs.kt | 34 +++++++++---------- .../com/powersync/DatabaseDriverFactory.kt | 16 ++++++--- .../powersync/DatabaseDriverFactory.jvm.kt | 12 +++---- .../DatabaseDriverFactory.watchos.kt | 34 +++++++++---------- 5 files changed, 53 insertions(+), 51 deletions(-) diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 7087ac1d..f8115e16 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -7,11 +7,11 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver public actual class DatabaseDriverFactory( private val context: Context, ) { - internal actual fun addPowerSyncExtension(driver: BundledSQLiteDriver) { - driver.addExtension("libpowersync.so", "sqlite3_powersync_init") - } - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { return context.getDatabasePath(dbFilename).path } } + +public actual fun BundledSQLiteDriver.addPowerSyncExtension() { + addExtension("libpowersync.so", "sqlite3_powersync_init") +} diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index 805b8457..76fcc7a4 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -6,27 +6,25 @@ import kotlin.getValue @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) public actual class DatabaseDriverFactory { - internal actual fun addPowerSyncExtension(driver: BundledSQLiteDriver) { - driver.addExtension(powerSyncExtensionPath, "sqlite3_powersync_init") - } - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { return appleDefaultDatabasePath(dbFilename) } +} - private companion object { - val powerSyncExtensionPath by lazy { - // Try and find the bundle path for the SQLite core extension. - val bundlePath = - NSBundle.bundleWithIdentifier("co.powersync.sqlitecore")?.bundlePath - ?: // The bundle is not installed in the project - throw PowerSyncException( - "Please install the PowerSync SQLite core extension", - cause = Exception("The `co.powersync.sqlitecore` bundle could not be found in the project."), - ) +public actual fun BundledSQLiteDriver.addPowerSyncExtension() { + addExtension(powerSyncExtensionPath, "sqlite3_powersync_init") +} - // Construct full path to the shared library inside the bundle - bundlePath.let { "$it/powersync-sqlite-core" } - } - } +private val powerSyncExtensionPath: String by lazy { + // Try and find the bundle path for the SQLite core extension. + val bundlePath = + NSBundle.bundleWithIdentifier("co.powersync.sqlitecore")?.bundlePath + ?: // The bundle is not installed in the project + throw PowerSyncException( + "Please install the PowerSync SQLite core extension", + cause = Exception("The `co.powersync.sqlitecore` bundle could not be found in the project."), + ) + + // Construct full path to the shared library inside the bundle + bundlePath.let { "$it/powersync-sqlite-core" } } diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 47b5b025..6ea0f84e 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -8,11 +8,20 @@ import androidx.sqlite.driver.bundled.SQLITE_OPEN_READWRITE @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public expect class DatabaseDriverFactory { - internal fun addPowerSyncExtension(driver: BundledSQLiteDriver) - internal fun resolveDefaultDatabasePath(dbFilename: String): String } +/** + * Registers the PowerSync core extension on connections opened by this [BundledSQLiteDriver]. + * + * This method will be invoked by the PowerSync SDK when creating new databases. When using + * [PowerSyncDatabase.opened] with an existing connection pool, you should configure the driver + * backing that pool to load the extension. + */ +@ExperimentalPowerSyncAPI() +public expect fun BundledSQLiteDriver.addPowerSyncExtension() + +@OptIn(ExperimentalPowerSyncAPI::class) internal fun openDatabase( factory: DatabaseDriverFactory, dbFilename: String, @@ -27,8 +36,7 @@ internal fun openDatabase( factory.resolveDefaultDatabasePath(dbFilename) } - factory.addPowerSyncExtension(driver) - + driver.addPowerSyncExtension() return driver.open(dbPath, if (readOnly) { SQLITE_OPEN_READONLY } else { diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index aa050d21..1573a37a 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -4,15 +4,13 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") public actual class DatabaseDriverFactory { - internal actual fun addPowerSyncExtension(driver: BundledSQLiteDriver) { - driver.addExtension(powersyncExtension, "sqlite3_powersync_init") - } - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { return dbFilename } +} - public companion object { - private val powersyncExtension: String = extractLib("powersync") - } +public actual fun BundledSQLiteDriver.addPowerSyncExtension() { + addExtension(powersyncExtension, "sqlite3_powersync_init") } + +private val powersyncExtension: String by lazy { extractLib("powersync") } diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index 2121c72c..751fed0e 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -5,28 +5,26 @@ import com.powersync.static.powersync_init_static @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) public actual class DatabaseDriverFactory { - internal actual fun addPowerSyncExtension(driver: BundledSQLiteDriver) { - didLoadExtension - } - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { return appleDefaultDatabasePath(dbFilename) } +} - private companion object { - val didLoadExtension by lazy { - val rc = powersync_init_static() - if (rc != 0) { - throw PowerSyncException( - "Could not load the PowerSync SQLite core extension", - cause = - Exception( - "Calling powersync_init_static returned result code $rc", - ), - ) - } +public actual fun BundledSQLiteDriver.addPowerSyncExtension() { + didLoadExtension +} - true - } +private val didLoadExtension by lazy { + val rc = powersync_init_static() + if (rc != 0) { + throw PowerSyncException( + "Could not load the PowerSync SQLite core extension", + cause = + Exception( + "Calling powersync_init_static returned result code $rc", + ), + ) } + + true } From 011b1da88e4d23066240eb28573a93a64daf4f21 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 22 Aug 2025 14:30:11 +0200 Subject: [PATCH 10/28] Actually, use callbacks --- .../kotlin/com/powersync/DatabaseTest.kt | 46 +++++++++++-------- .../kotlin/com/powersync/PowerSyncDatabase.kt | 4 ++ .../com/powersync/db/PowerSyncDatabaseImpl.kt | 8 +++- .../kotlin/com/powersync/db/Queries.kt | 6 +-- .../db/driver/InternalConnectionPool.kt | 40 ++++++++-------- .../powersync/db/driver/RawConnectionLease.kt | 9 ---- .../com/powersync/db/driver/ReadPool.kt | 25 +++++----- .../db/driver/SQLiteConnectionPool.kt | 10 +--- .../db/internal/InternalDatabaseImpl.kt | 24 ++++------ 9 files changed, 83 insertions(+), 89 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index b40440d5..33a96459 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -22,6 +22,8 @@ import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch @@ -503,37 +505,45 @@ class DatabaseTest { @Test @OptIn(ExperimentalPowerSyncAPI::class) - fun testLeaseReadOnly() = + fun testUseRawReadOnly() = databaseTest { database.execute( "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", listOf("a", "a@example.org"), ) - - val raw = database.leaseConnection(readOnly = true) - raw.usePrepared("SELECT * FROM users") { stmt -> - stmt.step() shouldBe true - stmt.getText(1) shouldBe "a" - stmt.getText(2) shouldBe "a@example.org" + database.useConnection(true) { + it.usePrepared("SELECT * FROM users") { stmt -> + stmt.step() shouldBe true + stmt.getText(1) shouldBe "a" + stmt.getText(2) shouldBe "a@example.org" + } } - raw.close() } @Test @OptIn(ExperimentalPowerSyncAPI::class) - fun testLeaseWrite() = + fun testUseRawWrite() = databaseTest { - val raw = database.leaseConnection(readOnly = false) - raw.usePrepared("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)") { stmt -> - stmt.bindText(1, "name") - stmt.bindText(2, "email") - stmt.step() shouldBe false - - stmt.reset() - stmt.step() shouldBe false + val didWrite = CompletableDeferred() + + val job = scope.launch { + database.useConnection(readOnly = false) { raw -> + raw.usePrepared("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)") { stmt -> + stmt.bindText(1, "name") + stmt.bindText(2, "email") + stmt.step() shouldBe false + + stmt.reset() + stmt.step() shouldBe false + } + + didWrite.complete(Unit) + awaitCancellation() + } } + didWrite.await() database.getAll("SELECT * FROM users") { it.getString("name") } shouldHaveSize 2 // Verify that the statement indeed holds a lock on the database. @@ -548,7 +558,7 @@ class DatabaseTest { delay(100.milliseconds) hadOtherWrite.isCompleted shouldBe false - raw.close() + job.cancelAndJoin() hadOtherWrite.await() } } diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 178aae69..4535da64 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -233,5 +233,9 @@ public interface PowerSyncDatabase : Queries { group, ) } + + public fun databaseGroup(logger: Logger, identifier: String): Pair { + return ActiveDatabaseGroup.referenceDatabase(logger, identifier) + } } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 1642d73c..5699012e 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -323,11 +323,15 @@ internal class PowerSyncDatabaseImpl( } @ExperimentalPowerSyncAPI -override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnectionLease { + override suspend fun useConnection( + readOnly: Boolean, + block: suspend (SQLiteConnectionLease) -> T + ): T { waitReady() - return internalDb.leaseConnection(readOnly) + return internalDb.useConnection(readOnly, block) } + 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 ab9f811c..2796e7d1 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt @@ -192,15 +192,11 @@ public interface Queries { * * This is useful when you need full control over the raw statements to use. * - * The connection needs to be released by calling [SQLiteConnectionLease.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): SQLiteConnectionLease + public suspend fun useConnection(readOnly: Boolean = false, block: suspend (SQLiteConnectionLease) -> T): T } diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt index cc3fff54..3df11603 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @OptIn(ExperimentalPowerSyncAPI::class) internal class InternalConnectionPool( @@ -65,40 +66,37 @@ internal class InternalConnectionPool( return connection } - override suspend fun read(): SQLiteConnectionLease { - return readPool.obtainConnection() + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { + return readPool.read(callback) } - override suspend fun write(): SQLiteConnectionLease { - writeLockMutex.lock() - return RawConnectionLease(writeConnection) { - // When we've leased a write connection, we may have to update table update flows - // after users ran their custom statements. - writeConnection.prepare("SELECT powersync_update_hooks('get')").use { - check(it.step()) - val updatedTables = JsonUtil.json.decodeFromString>(it.getText(0)) - if (updatedTables.isNotEmpty()) { - scope.launch { - tableUpdatesFlow.emit(updatedTables) + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { + return writeLockMutex.withLock { + try { + callback(RawConnectionLease(writeConnection)) + } finally { + // When we've leased a write connection, we may have to update table update flows + // after users ran their custom statements. + writeConnection.prepare("SELECT powersync_update_hooks('get')").use { + check(it.step()) + val updatedTables = JsonUtil.json.decodeFromString>(it.getText(0)) + if (updatedTables.isNotEmpty()) { + scope.launch { + tableUpdatesFlow.emit(updatedTables) + } } } } - - writeLockMutex.unlock() } } override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { // First get a lock on all read connections readPool.withAllConnections { rawReadConnections -> - val readers = rawReadConnections.map { RawConnectionLease(it) {} } + val readers = rawReadConnections.map { RawConnectionLease(it) } // Then get access to the write connection - val writer = write() - - try { + write { writer -> action(writer, readers) - } finally { - writer.close() } } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt index d0b45fbc..03405b20 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt @@ -11,7 +11,6 @@ import com.powersync.ExperimentalPowerSyncAPI @OptIn(ExperimentalPowerSyncAPI::class) internal class RawConnectionLease( private val connection: SQLiteConnection, - private val returnConnection: () -> Unit, ) : SQLiteConnectionLease { private var isCompleted = false @@ -39,12 +38,4 @@ internal class RawConnectionLease( checkNotCompleted() return connection.prepare(sql).use(block) } - - override suspend fun close() { - // Note: This is a lease, don't close the underlying connection. - if (!isCompleted) { - isCompleted = true - returnConnection() - } - } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt index 095a3e28..089cbf0c 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt @@ -43,18 +43,21 @@ internal class ReadPool( } } - suspend fun obtainConnection(): RawConnectionLease { - val (connection, done) = - try { - available.receive() - } catch (e: PoolClosedException) { - throw PowerSyncException( - message = "Cannot process connection pool request", - cause = e, - ) - } + suspend fun read(block: suspend (SQLiteConnectionLease) -> T): T { + val (connection, done) = try { + available.receive() + } catch (e: PoolClosedException) { + throw PowerSyncException( + message = "Cannot process connection pool request", + cause = e, + ) + } - return RawConnectionLease(connection) { done.complete(Unit) } + try { + return block(RawConnectionLease(connection)) + } finally { + done.complete(Unit) + } } suspend fun withAllConnections(action: suspend (connections: List) -> R): R { diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt index 1e32d1da..ab8e16a2 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt @@ -7,15 +7,14 @@ import kotlinx.coroutines.runBlocking @ExperimentalPowerSyncAPI() public interface SQLiteConnectionPool { - public suspend fun read(): SQLiteConnectionLease + public suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T + public suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T public suspend fun withAllConnections(action: suspend ( writer: SQLiteConnectionLease, readers: List ) -> R) - public suspend fun write(): SQLiteConnectionLease - public val updates: SharedFlow> public suspend fun close() @@ -50,9 +49,4 @@ public interface SQLiteConnectionLease { it.step() } } - - /** - * Returns the leased connection to the pool. - */ - public suspend fun close() } 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 a268bd56..64817577 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -152,12 +152,14 @@ internal class InternalDatabaseImpl( } } - - override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnectionLease { + override suspend fun useConnection( + readOnly: Boolean, + block: suspend (SQLiteConnectionLease) -> T + ): T { return if (readOnly) { - pool.read() + pool.read(block) } else { - pool.write() + pool.write(block) } } @@ -168,14 +170,10 @@ internal class InternalDatabaseImpl( private suspend fun internalReadLock(callback: suspend (SQLiteConnectionLease) -> R): R = withContext(dbContext) { runWrapped { - val connection = leaseConnection(readOnly = true) - try { + useConnection(true) { connection -> catchSwiftExceptions { callback(connection) } - } finally { - // Closing the lease will release the connection back into the pool. - connection.close() } } } @@ -197,16 +195,12 @@ internal class InternalDatabaseImpl( @OptIn(ExperimentalPowerSyncAPI::class) private suspend fun internalWriteLock(callback: suspend (SQLiteConnectionLease) -> R): R = withContext(dbContext) { - val lease = pool.write() - try { + pool.write { writer -> runWrapped { catchSwiftExceptions { - callback(lease) + callback(writer) } } - } finally { - // Returning the lease will unlock the writeLockMutex - lease.close() } } From cb1373d782352d975e04f348f99db35fd9a3de0e Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 2 Sep 2025 17:20:27 +0200 Subject: [PATCH 11/28] Add docs --- .../kotlin/com/powersync/PowerSyncDatabase.kt | 6 +++++ .../db/driver/SQLiteConnectionPool.kt | 27 +++++++++++++++++++ gradle/libs.versions.toml | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 4535da64..321106df 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -216,6 +216,11 @@ public interface PowerSyncDatabase : Queries { * * In this case, PowerSync will not open its own SQLite connections, but rather refer to * connections in the [pool]. + * + * The `group` parameter should likely be teh result of calling [databaseGroup] - this is + * responsible for ensuring two instances of the same database file don't sync at the same + * time. So, a value that uniquely identifies the database should be passed to + * [databaseGroup]. */ @ExperimentalPowerSyncAPI public fun opened( @@ -234,6 +239,7 @@ public interface PowerSyncDatabase : Queries { ) } + @ExperimentalPowerSyncAPI public fun databaseGroup(logger: Logger, identifier: String): Pair { return ActiveDatabaseGroup.referenceDatabase(logger, identifier) } diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt index ab8e16a2..2a3e62b6 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt @@ -5,18 +5,45 @@ import com.powersync.ExperimentalPowerSyncAPI import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.runBlocking +/** + * An implementation of a connection pool providing asynchronous access to a single writer + * and multiple readers. + * + * This is the underlying pool implementation on which the higher-level PowerSync Kotlin SDK is + * built on. The SDK provides its own pool, but can also use existing implementations (via + * [com.powersync.PowerSyncDatabase.opened]). + */ @ExperimentalPowerSyncAPI() public interface SQLiteConnectionPool { + /** + * Calls the callback with a read-only connection temporarily leased from the pool. + */ public suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T + + /** + * Calls the callback with a read-write connection temporarily leased from the pool. + */ public suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T + /** + * Invokes the callback with all connections leased from the pool. + */ public suspend fun withAllConnections(action: suspend ( writer: SQLiteConnectionLease, readers: List ) -> R) + /** + * Returns a flow of table updates made on the [write] connection. + */ public val updates: SharedFlow> + /** + * Closes the connection pool and associated resources. + * + * Calling [read], [write] and [withAllConnections] after calling [close] should result in an + * exception. + */ public suspend fun close() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd9666d9..9224ed7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ kotlinx-datetime = "0.7.1" kotlinx-io = "0.8.0" ktor = "3.2.3" uuid = "0.8.4" -powersync-core = "0.4.4" +powersync-core = "0.4.5" turbine = "1.2.1" kotest = "5.9.1" # we can't upgrade to 6.x because that requires Java 11 or above (we need Java 8 support) From aa4e7bc10f8dc2c39a7823be8bfcb371f5dd6353 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 2 Sep 2025 17:30:36 +0200 Subject: [PATCH 12/28] Fix lints --- .../DatabaseDriverFactory.android.kt | 4 +-- .../powersync/DatabaseDriverFactory.apple.kt | 16 +++++++++++ .../DatabaseDriverFactory.appleNonWatchOs.kt | 18 +------------ .../powersync/DatabaseDriverFactoryTest.kt | 2 +- .../kotlin/com/powersync/DatabaseTest.kt | 27 ++++++++++--------- .../com/powersync/DatabaseDriverFactory.kt | 13 +++++---- .../kotlin/com/powersync/PowerSyncDatabase.kt | 12 ++++----- .../com/powersync/PowerSyncDatabaseFactory.kt | 15 ++++++----- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 3 +-- .../kotlin/com/powersync/db/Queries.kt | 5 +++- .../db/driver/InternalConnectionPool.kt | 27 +++++++++---------- .../powersync/db/driver/RawConnectionLease.kt | 15 +++++------ .../com/powersync/db/driver/ReadPool.kt | 18 ++++++------- .../db/driver/SQLiteConnectionPool.kt | 27 +++++++++++-------- .../db/internal/ConnectionContext.kt | 5 ++-- .../db/internal/InternalDatabaseImpl.kt | 10 +++---- .../powersync/DatabaseDriverFactory.jvm.kt | 4 +-- .../DatabaseDriverFactory.watchos.kt | 4 +-- 18 files changed, 112 insertions(+), 113 deletions(-) diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index f8115e16..30ab3da9 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -7,9 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver public actual class DatabaseDriverFactory( private val context: Context, ) { - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { - return context.getDatabasePath(dbFilename).path - } + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = context.getDatabasePath(dbFilename).path } public actual fun BundledSQLiteDriver.addPowerSyncExtension() { diff --git a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt index a168741b..acc25d9b 100644 --- a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt +++ b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt @@ -2,9 +2,11 @@ package com.powersync import kotlinx.cinterop.UnsafeNumber import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSBundle import platform.Foundation.NSFileManager import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSUserDomainMask +import kotlin.getValue @OptIn(UnsafeNumber::class) internal fun appleDefaultDatabasePath(dbFilename: String): String { @@ -22,3 +24,17 @@ internal fun appleDefaultDatabasePath(dbFilename: String): String { return databaseDirectory } + +internal val powerSyncExtensionPath: String by lazy { + // Try and find the bundle path for the SQLite core extension. + val bundlePath = + NSBundle.bundleWithIdentifier("co.powersync.sqlitecore")?.bundlePath + ?: // The bundle is not installed in the project + throw PowerSyncException( + "Please install the PowerSync SQLite core extension", + cause = Exception("The `co.powersync.sqlitecore` bundle could not be found in the project."), + ) + + // Construct full path to the shared library inside the bundle + bundlePath.let { "$it/powersync-sqlite-core" } +} diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index 76fcc7a4..865ba410 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -6,25 +6,9 @@ import kotlin.getValue @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) public actual class DatabaseDriverFactory { - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { - return appleDefaultDatabasePath(dbFilename) - } + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) } public actual fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension(powerSyncExtensionPath, "sqlite3_powersync_init") } - -private val powerSyncExtensionPath: String by lazy { - // Try and find the bundle path for the SQLite core extension. - val bundlePath = - NSBundle.bundleWithIdentifier("co.powersync.sqlitecore")?.bundlePath - ?: // The bundle is not installed in the project - throw PowerSyncException( - "Please install the PowerSync SQLite core extension", - cause = Exception("The `co.powersync.sqlitecore` bundle could not be found in the project."), - ) - - // Construct full path to the shared library inside the bundle - bundlePath.let { "$it/powersync-sqlite-core" } -} diff --git a/core/src/appleTest/kotlin/com/powersync/DatabaseDriverFactoryTest.kt b/core/src/appleTest/kotlin/com/powersync/DatabaseDriverFactoryTest.kt index 01ac23c2..e3052ffe 100644 --- a/core/src/appleTest/kotlin/com/powersync/DatabaseDriverFactoryTest.kt +++ b/core/src/appleTest/kotlin/com/powersync/DatabaseDriverFactoryTest.kt @@ -10,7 +10,7 @@ class DatabaseDriverFactoryTest { if (Platform.osFamily != OsFamily.WATCHOS) { // On watchOS targets, there's no special extension path because we expect to link the // PowerSync extension statically due to platform restrictions. - DatabaseDriverFactory.powerSyncExtensionPath + powerSyncExtensionPath } } } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 33a96459..ee91a2d5 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -1,8 +1,8 @@ package com.powersync -import app.cash.turbine.test import androidx.sqlite.SQLiteConnection import androidx.sqlite.execSQL +import app.cash.turbine.test import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi import com.powersync.db.ActiveDatabaseGroup @@ -527,21 +527,22 @@ class DatabaseTest { databaseTest { val didWrite = CompletableDeferred() - val job = scope.launch { - database.useConnection(readOnly = false) { raw -> - raw.usePrepared("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)") { stmt -> - stmt.bindText(1, "name") - stmt.bindText(2, "email") - stmt.step() shouldBe false + val job = + scope.launch { + database.useConnection(readOnly = false) { raw -> + raw.usePrepared("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)") { stmt -> + stmt.bindText(1, "name") + stmt.bindText(2, "email") + stmt.step() shouldBe false - stmt.reset() - stmt.step() shouldBe false - } + stmt.reset() + stmt.step() shouldBe false + } - didWrite.complete(Unit) - awaitCancellation() + didWrite.complete(Unit) + awaitCancellation() + } } - } didWrite.await() database.getAll("SELECT * FROM users") { it.getString("name") } shouldHaveSize 2 diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 6ea0f84e..36f80076 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -37,9 +37,12 @@ internal fun openDatabase( } driver.addPowerSyncExtension() - return driver.open(dbPath, if (readOnly) { - SQLITE_OPEN_READONLY - } else { - SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE - }) + return driver.open( + dbPath, + if (readOnly) { + SQLITE_OPEN_READONLY + } else { + SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE + }, + ) } diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 321106df..109e7345 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -229,19 +229,19 @@ public interface PowerSyncDatabase : Queries { schema: Schema, group: Pair, logger: Logger, - ): PowerSyncDatabase { - return PowerSyncDatabaseImpl( + ): PowerSyncDatabase = + PowerSyncDatabaseImpl( schema, scope, pool, logger, group, ) - } @ExperimentalPowerSyncAPI - public fun databaseGroup(logger: Logger, identifier: String): Pair { - return ActiveDatabaseGroup.referenceDatabase(logger, identifier) - } + public fun databaseGroup( + logger: Logger, + identifier: String, + ): Pair = ActiveDatabaseGroup.referenceDatabase(logger, identifier) } } diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt index 823af1e7..f8a3b217 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt @@ -54,13 +54,14 @@ internal fun createPowerSyncDatabaseImpl( val identifier = dbDirectory + dbFilename val activeDatabaseGroup = ActiveDatabaseGroup.referenceDatabase(logger, identifier) - val pool = InternalConnectionPool( - factory, - scope, - dbFilename, - dbDirectory, - activeDatabaseGroup.first.group.writeLockMutex - ) + val pool = + InternalConnectionPool( + factory, + scope, + dbFilename, + dbDirectory, + activeDatabaseGroup.first.group.writeLockMutex, + ) return PowerSyncDatabase.opened( pool, diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 5699012e..92ec9cf2 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -325,13 +325,12 @@ internal class PowerSyncDatabaseImpl( @ExperimentalPowerSyncAPI override suspend fun useConnection( readOnly: Boolean, - block: suspend (SQLiteConnectionLease) -> T + block: suspend (SQLiteConnectionLease) -> T, ): T { waitReady() return internalDb.useConnection(readOnly, block) } - 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 2796e7d1..90e955f5 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt @@ -198,5 +198,8 @@ public interface Queries { */ @ExperimentalPowerSyncAPI() @HiddenFromObjC() - public suspend fun useConnection(readOnly: Boolean = false, block: suspend (SQLiteConnectionLease) -> T): T + public suspend fun useConnection( + readOnly: Boolean = false, + block: suspend (SQLiteConnectionLease) -> T, + ): T } diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt index 3df11603..b5258f2a 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt @@ -20,21 +20,21 @@ internal class InternalConnectionPool( private val dbFilename: String, private val dbDirectory: String?, private val writeLockMutex: Mutex, - ): SQLiteConnectionPool { - +) : SQLiteConnectionPool { private val writeConnection = newConnection(false) - private val readPool = ReadPool({ newConnection(true) }, scope=scope) + private val readPool = ReadPool({ newConnection(true) }, scope = scope) // MutableSharedFlow to emit batched table updates private val tableUpdatesFlow = MutableSharedFlow>(replay = 0) private fun newConnection(readOnly: Boolean): SQLiteConnection { - val connection = openDatabase( - factory = factory, - dbFilename = dbFilename, - dbDirectory = dbDirectory, - readOnly = false, - ) + val connection = + openDatabase( + factory = factory, + dbFilename = dbFilename, + dbDirectory = dbDirectory, + readOnly = false, + ) connection.execSQL("pragma journal_mode = WAL") connection.execSQL("pragma journal_size_limit = ${6 * 1024 * 1024}") @@ -66,12 +66,10 @@ internal class InternalConnectionPool( return connection } - override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { - return readPool.read(callback) - } + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T = readPool.read(callback) - override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { - return writeLockMutex.withLock { + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T = + writeLockMutex.withLock { try { callback(RawConnectionLease(writeConnection)) } finally { @@ -88,7 +86,6 @@ internal class InternalConnectionPool( } } } - } override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { // First get a lock on all read connections diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt index 03405b20..2ca56b8a 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt @@ -18,9 +18,7 @@ internal class RawConnectionLease( check(!isCompleted) { "Connection lease already closed" } } - override suspend fun isInTransaction(): Boolean { - return isInTransactionSync() - } + override suspend fun isInTransaction(): Boolean = isInTransactionSync() override fun isInTransactionSync(): Boolean { checkNotCompleted() @@ -29,12 +27,13 @@ internal class RawConnectionLease( override suspend fun usePrepared( sql: String, - block: (SQLiteStatement) -> R - ): R { - return usePreparedSync(sql, block) - } + block: (SQLiteStatement) -> R, + ): R = usePreparedSync(sql, block) - override fun usePreparedSync(sql: String, block: (SQLiteStatement) -> R): R { + override fun usePreparedSync( + sql: String, + block: (SQLiteStatement) -> R, + ): R { checkNotCompleted() return connection.prepare(sql).use(block) } diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt index 089cbf0c..d67cd746 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt @@ -44,14 +44,15 @@ internal class ReadPool( } suspend fun read(block: suspend (SQLiteConnectionLease) -> T): T { - val (connection, done) = try { - available.receive() - } catch (e: PoolClosedException) { - throw PowerSyncException( - message = "Cannot process connection pool request", - cause = e, - ) - } + val (connection, done) = + try { + available.receive() + } catch (e: PoolClosedException) { + throw PowerSyncException( + message = "Cannot process connection pool request", + cause = e, + ) + } try { return block(RawConnectionLease(connection)) @@ -91,7 +92,6 @@ internal class ReadPool( available.cancel(PoolClosedException) connections.joinAll() } - } internal object PoolClosedException : CancellationException("Pool is closed") diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt index 2a3e62b6..ceff6577 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt @@ -28,10 +28,12 @@ public interface SQLiteConnectionPool { /** * Invokes the callback with all connections leased from the pool. */ - public suspend fun withAllConnections(action: suspend ( - writer: SQLiteConnectionLease, - readers: List - ) -> R) + public suspend fun withAllConnections( + action: suspend ( + writer: SQLiteConnectionLease, + readers: List, + ) -> R, + ) /** * Returns a flow of table updates made on the [write] connection. @@ -54,22 +56,25 @@ public interface SQLiteConnectionLease { */ public suspend fun isInTransaction(): Boolean - public fun isInTransactionSync(): Boolean { - return runBlocking { isInTransaction() } - } + public fun isInTransactionSync(): Boolean = runBlocking { isInTransaction() } /** * Prepares [sql] as statement and runs [block] with it. * * Block most only run on a single-thread. The statement must not be used once [block] returns. */ - public suspend fun usePrepared(sql: String, block: (SQLiteStatement) -> R): R + public suspend fun usePrepared( + sql: String, + block: (SQLiteStatement) -> R, + ): R - public fun usePreparedSync(sql: String, block: (SQLiteStatement) -> R): R { - return runBlocking { + public fun usePreparedSync( + sql: String, + block: (SQLiteStatement) -> R, + ): R = + runBlocking { usePrepared(sql, block) } - } public suspend fun execSQL(sql: String) { usePrepared(sql) { 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 6c70780b..e80f2044 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt @@ -95,12 +95,11 @@ internal class ConnectionContextImplementation( sql: String, parameters: List?, crossinline block: (SQLiteStatement) -> T, - ): T { - return rawConnection.usePreparedSync(sql) { stmt -> + ): T = + rawConnection.usePreparedSync(sql) { stmt -> stmt.bind(parameters) block(stmt) } - } } internal fun SQLiteStatement.bind(parameters: List?) { 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 0b774ee0..d0d22b15 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -24,7 +24,7 @@ import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalPowerSyncAPI::class) internal class InternalDatabaseImpl( - private val pool: SQLiteConnectionPool + private val pool: SQLiteConnectionPool, ) : InternalDatabase { // Could be scope.coroutineContext, but the default is GlobalScope, which seems like a bad idea. To discuss. private val dbContext = Dispatchers.IO @@ -152,14 +152,13 @@ internal class InternalDatabaseImpl( override suspend fun useConnection( readOnly: Boolean, - block: suspend (SQLiteConnectionLease) -> T - ): T { - return if (readOnly) { + block: suspend (SQLiteConnectionLease) -> T, + ): T = + if (readOnly) { pool.read(block) } else { pool.write(block) } - } /** * Creates a read lock while providing an internal transactor for transactions @@ -203,7 +202,6 @@ internal class InternalDatabaseImpl( override suspend fun writeTransaction(callback: ThrowableTransactionCallback): R = internalWriteLock { - it.runTransaction { tx -> callback.execute(tx) } diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 1573a37a..d309a4f4 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -4,9 +4,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") public actual class DatabaseDriverFactory { - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { - return dbFilename - } + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = dbFilename } public actual fun BundledSQLiteDriver.addPowerSyncExtension() { diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index 751fed0e..2294e423 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -5,9 +5,7 @@ import com.powersync.static.powersync_init_static @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) public actual class DatabaseDriverFactory { - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String { - return appleDefaultDatabasePath(dbFilename) - } + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) } public actual fun BundledSQLiteDriver.addPowerSyncExtension() { From 03796e731a16c984d4568795590c8cbabbfb00e3 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 5 Sep 2025 12:49:02 +0200 Subject: [PATCH 13/28] Add native sqlite driver --- core/build.gradle.kts | 18 ++- .../DatabaseDriverFactory.android.kt | 9 +- .../DatabaseDriverFactory.appleNonWatchOs.kt | 20 ++- .../com/powersync/sqlite/DatabaseTest.kt | 25 +++ .../com/powersync/DatabaseDriverFactory.kt | 41 ++--- .../powersync/DatabaseDriverFactory.jvm.kt | 9 +- core/src/nativeMain/interop/sqlite3.def | 3 + core/src/nativeMain/interop/sqlite3.h | 63 ++++++++ .../kotlin/com/powersync/sqlite/Database.kt | 90 +++++++++++ .../com/powersync/sqlite/SqliteException.kt | 65 ++++++++ .../kotlin/com/powersync/sqlite/Statement.kt | 151 ++++++++++++++++++ .../DatabaseDriverFactory.watchos.kt | 10 +- 12 files changed, 466 insertions(+), 38 deletions(-) create mode 100644 core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt create mode 100644 core/src/nativeMain/interop/sqlite3.def create mode 100644 core/src/nativeMain/interop/sqlite3.h create mode 100644 core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt create mode 100644 core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt create mode 100644 core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c6c24b5f..fae66095 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -118,6 +118,12 @@ kotlin { headers(file("src/watchosMain/powersync_static.h")) } } + + cinterops.create("sqlite3") { + packageName("com.powersync.internal.sqlite3") + includeDirs.allHeaders("src/nativeMain/interop/") + definitionFile.set(project.file("src/nativeMain/interop/sqlite3.def")) + } } } @@ -158,7 +164,6 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.stately.concurrency) implementation(libs.configuration.annotations) - implementation(libs.androidx.sqlite.bundled) api(libs.ktor.client.core) api(libs.kermit) } @@ -166,7 +171,10 @@ kotlin { androidMain { dependsOn(commonJava) - dependencies.implementation(libs.ktor.client.okhttp) + dependencies { + implementation(libs.ktor.client.okhttp) + implementation(libs.androidx.sqlite.bundled) + } } jvmMain { @@ -174,11 +182,17 @@ kotlin { dependencies { implementation(libs.ktor.client.okhttp) + implementation(libs.androidx.sqlite.bundled) } } appleMain.dependencies { implementation(libs.ktor.client.darwin) + + // We're not using the bundled SQLite library for Apple platforms. Instead, we depend on + // static-sqlite-driver to link SQLite and have our own bindings implementing the + // driver. The reason for this is that androidx.sqlite-bundled causes linker errors for + // our Swift SDK. } // Common apple targets where we link the core extension dynamically diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 30ab3da9..6490bba9 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -1,15 +1,22 @@ package com.powersync import android.content.Context +import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public actual class DatabaseDriverFactory( private val context: Context, ) { + private val driver = BundledSQLiteDriver().also { it.addPowerSyncExtension() } + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = context.getDatabasePath(dbFilename).path + + internal actual fun openConnection(path: String, openFlags: Int): SQLiteConnection { + return driver.open(path, openFlags) + } } -public actual fun BundledSQLiteDriver.addPowerSyncExtension() { +public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension("libpowersync.so", "sqlite3_powersync_init") } diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index 865ba410..db255050 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -1,14 +1,22 @@ package com.powersync -import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import platform.Foundation.NSBundle -import kotlin.getValue +import androidx.sqlite.SQLiteConnection +import com.powersync.sqlite.Database +import com.powersync.sqlite.SqliteException + @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) public actual class DatabaseDriverFactory { internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) -} -public actual fun BundledSQLiteDriver.addPowerSyncExtension() { - addExtension(powerSyncExtensionPath, "sqlite3_powersync_init") + internal actual fun openConnection(path: String, openFlags: Int): SQLiteConnection { + val db = Database.open(path, openFlags) + try { + db.loadExtension(powerSyncExtensionPath, "sqlite3_powersync_init") + } catch (e: SqliteException) { + db.close() + throw e + } + return db + } } diff --git a/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt b/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt new file mode 100644 index 00000000..c340c93e --- /dev/null +++ b/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt @@ -0,0 +1,25 @@ +package com.powersync.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class DatabaseTest { + @Test + fun testInTransaction() = inMemoryDatabase().use { + it.inTransaction() shouldBe false + it.execSQL("BEGIN") + it.inTransaction() shouldBe true + it.execSQL("COMMIT") + it.inTransaction() shouldBe false + + Unit + } + + private companion object { + private fun inMemoryDatabase(): SQLiteConnection { + return Database.open(":memory", 0) + } + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 36f80076..182289ce 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -1,25 +1,19 @@ package com.powersync import androidx.sqlite.SQLiteConnection -import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import androidx.sqlite.driver.bundled.SQLITE_OPEN_CREATE -import androidx.sqlite.driver.bundled.SQLITE_OPEN_READONLY -import androidx.sqlite.driver.bundled.SQLITE_OPEN_READWRITE + @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public expect class DatabaseDriverFactory { internal fun resolveDefaultDatabasePath(dbFilename: String): String -} -/** - * Registers the PowerSync core extension on connections opened by this [BundledSQLiteDriver]. - * - * This method will be invoked by the PowerSync SDK when creating new databases. When using - * [PowerSyncDatabase.opened] with an existing connection pool, you should configure the driver - * backing that pool to load the extension. - */ -@ExperimentalPowerSyncAPI() -public expect fun BundledSQLiteDriver.addPowerSyncExtension() + /** + * Opens a SQLite connection on [path] with [openFlags]. + * + * The connection should have the PowerSync core extension loaded. + */ + internal fun openConnection(path: String, openFlags: Int): SQLiteConnection +} @OptIn(ExperimentalPowerSyncAPI::class) internal fun openDatabase( @@ -28,7 +22,6 @@ internal fun openDatabase( dbDirectory: String?, readOnly: Boolean = false, ): SQLiteConnection { - val driver = BundledSQLiteDriver() val dbPath = if (dbDirectory != null) { "$dbDirectory/$dbFilename" @@ -36,13 +29,13 @@ internal fun openDatabase( factory.resolveDefaultDatabasePath(dbFilename) } - driver.addPowerSyncExtension() - return driver.open( - dbPath, - if (readOnly) { - SQLITE_OPEN_READONLY - } else { - SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE - }, - ) + return factory.openConnection(dbPath, if (readOnly) { + SQLITE_OPEN_READONLY + } else { + SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE + },) } + +private const val SQLITE_OPEN_READONLY = 0x01 +private const val SQLITE_OPEN_READWRITE = 0x02 +private const val SQLITE_OPEN_CREATE = 0x04 diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index d309a4f4..beaf14e9 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -1,13 +1,20 @@ package com.powersync +import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") public actual class DatabaseDriverFactory { + private val driver = BundledSQLiteDriver().also { it.addPowerSyncExtension() } + internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = dbFilename + + internal actual fun openConnection(path: String, openFlags: Int): SQLiteConnection { + return driver.open(path, openFlags) + } } -public actual fun BundledSQLiteDriver.addPowerSyncExtension() { +public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension(powersyncExtension, "sqlite3_powersync_init") } diff --git a/core/src/nativeMain/interop/sqlite3.def b/core/src/nativeMain/interop/sqlite3.def new file mode 100644 index 00000000..e90eb403 --- /dev/null +++ b/core/src/nativeMain/interop/sqlite3.def @@ -0,0 +1,3 @@ +headers = sqlite3.h + +noStringConversion = sqlite3_prepare_v3 diff --git a/core/src/nativeMain/interop/sqlite3.h b/core/src/nativeMain/interop/sqlite3.h new file mode 100644 index 00000000..63ceb67f --- /dev/null +++ b/core/src/nativeMain/interop/sqlite3.h @@ -0,0 +1,63 @@ +// A subset of sqlite3.h that only includes the symbols this Kotlin package needs. +#include + +typedef struct sqlite3 sqlite3; +typedef struct sqlite3_stmt sqlite3_stmt; + +int sqlite3_initialize(); + +int sqlite3_open_v2(char *filename, sqlite3 **ppDb, int flags, + char *zVfs); +int sqlite3_close_v2(sqlite3 *db); + +// Error handling +int sqlite3_extended_result_codes(sqlite3 *db, int onoff); +int sqlite3_extended_errcode(sqlite3 *db); +char *sqlite3_errmsg(sqlite3 *db); +char *sqlite3_errstr(int code); +int sqlite3_error_offset(sqlite3 *db); +void sqlite3_free(void *ptr); + +// Versions +char *sqlite3_libversion(); +char *sqlite3_sourceid(); +int sqlite3_libversion_number(); + +// Database +int sqlite3_get_autocommit(sqlite3 *db); +int sqlite3_db_config(sqlite3 *db, int op, ...); +int sqlite3_load_extension( + sqlite3 *db, /* Load the extension into this database connection */ + const char *zFile, /* Name of the shared library containing extension */ + const char *zProc, /* Entry point. Derived from zFile if 0 */ + char **pzErrMsg /* Put error message here if not 0 */ +); + +// Statements +int sqlite3_prepare_v3(sqlite3 *db, const char *zSql, int nByte, + unsigned int prepFlags, sqlite3_stmt **ppStmt, + const char **pzTail); +int sqlite3_finalize(sqlite3_stmt *pStmt); +int sqlite3_step(sqlite3_stmt *pStmt); +int sqlite3_reset(sqlite3_stmt *pStmt); +int sqlite3_clear_bindings(sqlite3_stmt*); + +int sqlite3_column_count(sqlite3_stmt *pStmt); +int sqlite3_bind_parameter_count(sqlite3_stmt *pStmt); +char *sqlite3_column_name(sqlite3_stmt *pStmt, int N); + +int sqlite3_bind_blob64(sqlite3_stmt *pStmt, int index, void *data, + uint64_t length, void *destructor); +int sqlite3_bind_double(sqlite3_stmt *pStmt, int index, double data); +int sqlite3_bind_int64(sqlite3_stmt *pStmt, int index, int64_t data); +int sqlite3_bind_null(sqlite3_stmt *pStmt, int index); +int sqlite3_bind_text16(sqlite3_stmt *pStmt, int index, char *data, + int length, void *destructor); + +void *sqlite3_column_blob(sqlite3_stmt *pStmt, int iCol); +double sqlite3_column_double(sqlite3_stmt *pStmt, int iCol); +int64_t sqlite3_column_int64(sqlite3_stmt *pStmt, int iCol); +char *sqlite3_column_text(sqlite3_stmt *pStmt, int iCol); +int sqlite3_column_bytes(sqlite3_stmt *pStmt, int iCol); +int sqlite3_column_bytes16(sqlite3_stmt *pStmt, int iCol); +int sqlite3_column_type(sqlite3_stmt *pStmt, int iCol); diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt new file mode 100644 index 00000000..782cc21e --- /dev/null +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt @@ -0,0 +1,90 @@ +package com.powersync.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import cnames.structs.sqlite3 +import cnames.structs.sqlite3_stmt +import com.powersync.PowerSyncException +import com.powersync.internal.sqlite3.sqlite3_close_v2 +import com.powersync.internal.sqlite3.sqlite3_get_autocommit +import com.powersync.internal.sqlite3.sqlite3_initialize +import com.powersync.internal.sqlite3.sqlite3_open_v2 +import com.powersync.internal.sqlite3.sqlite3_prepare_v3 +import com.powersync.internal.sqlite3.sqlite3_db_config +import com.powersync.internal.sqlite3.sqlite3_free +import com.powersync.internal.sqlite3.sqlite3_load_extension +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.CPointerVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocPointerTo +import kotlinx.cinterop.cstr +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.toKStringFromUtf8 +import kotlinx.cinterop.utf16 +import kotlinx.cinterop.value + +internal class Database(private val ptr: CPointer): SQLiteConnection { + override fun inTransaction(): Boolean { + // We're in a transaction if autocommit is disabled + return sqlite3_get_autocommit(ptr) == 0 + } + + override fun prepare(sql: String): SQLiteStatement = memScoped { + val stmtPtr = allocPointerTo() + val asUtf16 = sql.utf16 + sqlite3_prepare_v3(ptr, asUtf16.ptr.reinterpret(), asUtf16.size, 0u, stmtPtr.ptr, null).checkResult() + + Statement(sql, ptr, stmtPtr.value!!) + } + + fun loadExtension(filename: String, entrypoint: String) = memScoped { + val errorMessagePointer = alloc>() + val resultCode = sqlite3_load_extension(ptr, filename, entrypoint, errorMessagePointer.ptr) + + if (resultCode != 0) { + val errorMessage = errorMessagePointer.value?.toKStringFromUtf8() + if (errorMessage != null) { + sqlite3_free(errorMessagePointer.value) + } + + throw SqliteException(resultCode, errorMessage ?: "unknown error") + } + } + + override fun close() { + sqlite3_close_v2(ptr) + } + + private fun Int.checkResult() { + if (this != 0) { + throw PowerSyncException("SQLite error", SqliteException.createExceptionInDatabase(this, ptr)) + } + } + + companion object { + fun open(path: String, flags: Int): Database = memScoped { + var rc = sqlite3_initialize() + if (rc != 0) { + throw SqliteException.createExceptionOutsideOfDatabase(rc) + } + + val encodedPath = path.cstr.getPointer(this) + val ptr = allocPointerTo() + rc = sqlite3_open_v2(encodedPath, ptr.ptr, flags, null) + if (rc != 0) { + throw SqliteException.createExceptionOutsideOfDatabase(rc) + } + + val db = ptr.value!! + // Enable extensions via the C API + sqlite3_db_config(db, DBCONFIG_ENABLE_LOAD_EXTENSION, 1, 0) + + Database(db) + } + + private const val DBCONFIG_ENABLE_LOAD_EXTENSION = 1005 + } +} diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt new file mode 100644 index 00000000..12046e82 --- /dev/null +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt @@ -0,0 +1,65 @@ +package com.powersync.sqlite + +import cnames.structs.sqlite3 +import com.powersync.internal.sqlite3.sqlite3_errmsg +import com.powersync.internal.sqlite3.sqlite3_error_offset +import com.powersync.internal.sqlite3.sqlite3_errstr +import com.powersync.internal.sqlite3.sqlite3_extended_errcode +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.toKStringFromUtf8 + +internal class SqliteException( + val code: Int, + message: String, + val extendedErrorCode: Int? = null, + val offset: Int? = null, + val dbMessage: String? = null, + val sql: String? = null +): Exception(message) { + + override fun toString(): String { + return buildString { + append("SqliteException(") + append(extendedErrorCode ?: code) + append("): ") + append(message) + + offset?.let { + append(" at offset") + append(it) + } + + dbMessage?.let { + append(", ") + append(it) + } + + sql?.let { + append("for SQL: ") + append(it) + } + } + } + + companion object { + fun createExceptionOutsideOfDatabase(code: Int): SqliteException { + return SqliteException(code, sqlite3_errstr(code)!!.toKStringFromUtf8()) + } + + fun createExceptionInDatabase(code: Int, db: CPointer, sql: String? = null): SqliteException { + val extended = sqlite3_extended_errcode(db) + val offset = sqlite3_error_offset(db).takeIf { it >= 0 } + val dbMsg = sqlite3_errmsg(db)?.toKStringFromUtf8() + val errStr = sqlite3_errstr(extended)!!.toKStringFromUtf8() + + return SqliteException( + code, + errStr, + extended, + offset, + dbMsg, + sql, + ) + } + } +} diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt new file mode 100644 index 00000000..a6024824 --- /dev/null +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt @@ -0,0 +1,151 @@ +package com.powersync.sqlite + +import androidx.sqlite.SQLiteStatement +import cnames.structs.sqlite3 +import cnames.structs.sqlite3_stmt +import com.powersync.internal.sqlite3.* +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.CPointed +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UShortVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.get +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.readBytes +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.toCPointer +import kotlinx.cinterop.toKStringFromUtf8 +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.utf16 + +@OptIn(ExperimentalForeignApi::class) +internal class Statement( + private val sql: String, + private val db: CPointer, + private val ptr: CPointer +): SQLiteStatement { + override fun bindBlob(index: Int, value: ByteArray) { + value.usePinned { pinned -> + val valuePtr = pinned.addressOf(0) + sqlite3_bind_blob64( + ptr, + index, + valuePtr, + value.size.toULong(), + DESTRUCTOR_TRANSIENT + ) + } + } + + override fun bindDouble(index: Int, value: Double) { + sqlite3_bind_double(ptr, index, value).checkResult() + } + + override fun bindLong(index: Int, value: Long) { + sqlite3_bind_int64(ptr, index, value).checkResult() + } + + override fun bindText(index: Int, value: String) { + memScoped { + val utf16 = value.utf16 + sqlite3_bind_text16(ptr, index, utf16.ptr.reinterpret(), utf16.size, DESTRUCTOR_TRANSIENT) + } + } + + override fun bindNull(index: Int) { + sqlite3_bind_null(ptr, index).checkResult() + } + + override fun getBlob(index: Int): ByteArray { + val len = sqlite3_column_bytes(ptr, index) + if (len == 0) { + return byteArrayOf() + } + + val buf = sqlite3_column_blob(ptr, index)!! + return buf.reinterpret().readBytes(len) // Note: this copies + } + + override fun getDouble(index: Int): Double { + return sqlite3_column_double(ptr, index) + } + + override fun getLong(index: Int): Long { + return sqlite3_column_int64(ptr, index) + } + + override fun getText(index: Int): String { + val len = sqlite3_column_bytes16(ptr, index) + if (len == 0) { + return "" + } + + val utf16Ptr: CPointer = sqlite3_column_text( + ptr, + index + )!!.reinterpret() + val characters = CharArray(len) { utf16Ptr[it].toInt().toChar() } + return characters.concatToString() + } + + override fun isNull(index: Int): Boolean { + return sqlite3_column_type(ptr, index) == SQLITE_NULL + } + + override fun getColumnCount(): Int { + return sqlite3_column_count(ptr) + } + + override fun getColumnName(index: Int): String { + return sqlite3_column_name(ptr, index)!!.toKStringFromUtf8() + } + + override fun getColumnType(index: Int): Int { + return sqlite3_column_type(ptr, index) + } + + override fun step(): Boolean { + return when (val rc = sqlite3_step(ptr)) { + SQLITE_ROW -> true + SQLITE_DONE -> false + else -> throwException(rc) + } + } + + override fun reset() { + sqlite3_reset(ptr).checkResult() + } + + override fun clearBindings() { + sqlite3_clear_bindings(ptr).checkResult() + } + + override fun close() { + sqlite3_finalize(ptr).checkResult() + } + + private fun Int.checkResult() { + if (this != 0) { + throwException(this) + } + } + + private fun throwException(errorCode: Int): Nothing { + throw SqliteException.createExceptionInDatabase(errorCode, db, sql) + } + + private companion object { + const val SQLITE_INTEGER = 1 + const val SQLITE_FLOAT = 2 + const val SQLITE_TEXT = 3 + const val SQLITE_BLOB = 4 + const val SQLITE_NULL = 5 + + const val SQLITE_ROW = 100 + const val SQLITE_DONE = 101 + + val DESTRUCTOR_TRANSIENT: COpaquePointer = (-1L).toCPointer()!! + } +} diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index 2294e423..8cdf0996 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -1,15 +1,17 @@ package com.powersync -import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.SQLiteConnection +import com.powersync.sqlite.Database import com.powersync.static.powersync_init_static @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) public actual class DatabaseDriverFactory { internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) -} -public actual fun BundledSQLiteDriver.addPowerSyncExtension() { - didLoadExtension + internal actual fun openConnection(path: String, openFlags: Int): SQLiteConnection { + didLoadExtension + return Database.open(path, openFlags) + } } private val didLoadExtension by lazy { From a0682c2a9293d7de93153cb586b49c0c90994446 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 5 Sep 2025 14:10:09 +0200 Subject: [PATCH 14/28] Bring back static sqlite linking --- core/build.gradle.kts | 1 + .../DatabaseDriverFactory.android.kt | 7 +- .../DatabaseDriverFactory.appleNonWatchOs.kt | 6 +- .../com/powersync/sqlite/DatabaseTest.kt | 31 +++-- .../com/powersync/sqlite/StatementTest.kt | 122 ++++++++++++++++++ .../com/powersync/DatabaseDriverFactory.kt | 19 ++- .../powersync/DatabaseDriverFactory.jvm.kt | 7 +- core/src/nativeMain/interop/sqlite3.h | 13 +- .../kotlin/com/powersync/sqlite/Database.kt | 73 ++++++----- .../com/powersync/sqlite/SqliteException.kt | 24 ++-- .../kotlin/com/powersync/sqlite/Statement.kt | 86 ++++++------ .../DatabaseDriverFactory.watchos.kt | 5 +- .../com/powersync/compile/ClangCompile.kt | 114 ++++++++++++++++ .../powersync/compile/CreateSqliteCInterop.kt | 38 ++++++ .../powersync/compile/CreateStaticLibrary.kt | 37 ++++++ .../com/powersync/compile/UnzipSqlite.kt | 35 +++++ settings.gradle.kts | 1 + static-sqlite-driver/README.md | 1 + static-sqlite-driver/build.gradle.kts | 107 +++++++++++++++ static-sqlite-driver/gradle.properties | 3 + .../powersync/sqlite3/StaticSqliteDriver.kt | 9 ++ 21 files changed, 621 insertions(+), 118 deletions(-) create mode 100644 core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt create mode 100644 plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt create mode 100644 plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateSqliteCInterop.kt create mode 100644 plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateStaticLibrary.kt create mode 100644 plugins/build-plugin/src/main/kotlin/com/powersync/compile/UnzipSqlite.kt create mode 100644 static-sqlite-driver/README.md create mode 100644 static-sqlite-driver/build.gradle.kts create mode 100644 static-sqlite-driver/gradle.properties create mode 100644 static-sqlite-driver/src/commonMain/kotlin/com/powersync/sqlite3/StaticSqliteDriver.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index fae66095..9cbc2930 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -193,6 +193,7 @@ kotlin { // static-sqlite-driver to link SQLite and have our own bindings implementing the // driver. The reason for this is that androidx.sqlite-bundled causes linker errors for // our Swift SDK. + implementation(projects.staticSqliteDriver) } // Common apple targets where we link the core extension dynamically diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 6490bba9..ac3f1319 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -12,9 +12,10 @@ public actual class DatabaseDriverFactory( internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = context.getDatabasePath(dbFilename).path - internal actual fun openConnection(path: String, openFlags: Int): SQLiteConnection { - return driver.open(path, openFlags) - } + internal actual fun openConnection( + path: String, + openFlags: Int, + ): SQLiteConnection = driver.open(path, openFlags) } public fun BundledSQLiteDriver.addPowerSyncExtension() { diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index db255050..5c7b32d3 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -4,12 +4,14 @@ import androidx.sqlite.SQLiteConnection import com.powersync.sqlite.Database import com.powersync.sqlite.SqliteException - @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) public actual class DatabaseDriverFactory { internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) - internal actual fun openConnection(path: String, openFlags: Int): SQLiteConnection { + internal actual fun openConnection( + path: String, + openFlags: Int, + ): SQLiteConnection { val db = Database.open(path, openFlags) try { db.loadExtension(powerSyncExtensionPath, "sqlite3_powersync_init") diff --git a/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt b/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt index c340c93e..ca7107f1 100644 --- a/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt +++ b/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt @@ -2,24 +2,33 @@ package com.powersync.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.execSQL +import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import kotlin.test.Test class DatabaseTest { @Test - fun testInTransaction() = inMemoryDatabase().use { - it.inTransaction() shouldBe false - it.execSQL("BEGIN") - it.inTransaction() shouldBe true - it.execSQL("COMMIT") - it.inTransaction() shouldBe false + fun testInTransaction() = + inMemoryDatabase().use { + it.inTransaction() shouldBe false + it.execSQL("BEGIN") + it.inTransaction() shouldBe true + it.execSQL("COMMIT") + it.inTransaction() shouldBe false - Unit - } + Unit + } - private companion object { - private fun inMemoryDatabase(): SQLiteConnection { - return Database.open(":memory", 0) + @Test + fun syntaxError() = + inMemoryDatabase().use { + val exception = shouldThrow { it.execSQL("bad syntax") } + + exception.toString() shouldBe "SqliteException(1): SQL logic error at offset 0, near \"bad\": syntax error for SQL: bad syntax" + Unit } + + private companion object { + private fun inMemoryDatabase(): SQLiteConnection = Database.open(":memory:", 2) } } diff --git a/core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt b/core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt new file mode 100644 index 00000000..0c01c6fc --- /dev/null +++ b/core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt @@ -0,0 +1,122 @@ +package com.powersync.sqlite + +import androidx.sqlite.SQLiteConnection +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class StatementTest { + @Test + fun testBind() = + inMemoryDatabase().use { db -> + db.prepare("SELECT json_array(?, ?, ?, ?, hex(?))").use { stmt -> + stmt.bindDouble(1, 3.14) + stmt.bindLong(2, 42) + stmt.bindText(3, "foo") + stmt.bindNull(4) + stmt.bindBlob(5, byteArrayOf(1, 2, 3)) + + stmt.step() shouldBe true + stmt.getColumnType(0) shouldBe Statement.SQLITE_TEXT + stmt.getText(0) shouldBe "[3.14,42,\"foo\",null,\"010203\"]" + } + + Unit + } + + @Test + fun testBindOutOfBounds() = + inMemoryDatabase().use { db -> + db.prepare("SELECT ?").use { stmt -> + shouldThrow { + stmt.bindText(-1, "foo") + } + shouldThrow { + stmt.bindText(0, "foo") + } + + stmt.bindText(1, "foo") + + shouldThrow { + stmt.bindText(2, "foo") + } + } + Unit + } + + @Test + fun getBlob() = + inMemoryDatabase().use { db -> + db.prepare("SELECT unhex('010203')").use { stmt -> + stmt.step() shouldBe true + stmt.getColumnType(0) shouldBe Statement.SQLITE_BLOB + stmt.getBlob(0) shouldBe byteArrayOf(1, 2, 3) + } + Unit + } + + @Test + fun getDouble() = + inMemoryDatabase().use { db -> + db.prepare("SELECT 3.14").use { stmt -> + stmt.step() shouldBe true + stmt.getColumnType(0) shouldBe Statement.SQLITE_FLOAT + stmt.getDouble(0) shouldBe 3.14 + } + Unit + } + + @Test + fun getLong() = + inMemoryDatabase().use { db -> + db.prepare("SELECT 123").use { stmt -> + stmt.step() shouldBe true + stmt.getColumnType(0) shouldBe Statement.SQLITE_INTEGER + stmt.getLong(0) shouldBe 123L + stmt.getInt(0) shouldBe 123 + } + Unit + } + + @Test + fun getText() = + inMemoryDatabase().use { db -> + db.prepare("SELECT 'hello kotlin'").use { stmt -> + stmt.step() shouldBe true + stmt.getColumnType(0) shouldBe Statement.SQLITE_TEXT + stmt.getText(0) shouldBe "hello kotlin" + } + Unit + } + + @Test + fun getNull() = + inMemoryDatabase().use { db -> + db.prepare("SELECT null").use { stmt -> + stmt.step() shouldBe true + stmt.isNull(0) shouldBe true + } + Unit + } + + @Test + fun getOutOfBound() = + inMemoryDatabase().use { db -> + db.prepare("SELECT null").use { stmt -> + stmt.step() shouldBe true + + shouldThrow { + stmt.getInt(-1) + } + + shouldThrow { + stmt.getInt(1) + } + } + Unit + } + + private companion object { + private fun inMemoryDatabase(): SQLiteConnection = Database.open(":memory:", 2) + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 182289ce..6be048e5 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -2,7 +2,6 @@ package com.powersync import androidx.sqlite.SQLiteConnection - @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public expect class DatabaseDriverFactory { internal fun resolveDefaultDatabasePath(dbFilename: String): String @@ -12,7 +11,10 @@ public expect class DatabaseDriverFactory { * * The connection should have the PowerSync core extension loaded. */ - internal fun openConnection(path: String, openFlags: Int): SQLiteConnection + internal fun openConnection( + path: String, + openFlags: Int, + ): SQLiteConnection } @OptIn(ExperimentalPowerSyncAPI::class) @@ -29,11 +31,14 @@ internal fun openDatabase( factory.resolveDefaultDatabasePath(dbFilename) } - return factory.openConnection(dbPath, if (readOnly) { - SQLITE_OPEN_READONLY - } else { - SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE - },) + return factory.openConnection( + dbPath, + if (readOnly) { + SQLITE_OPEN_READONLY + } else { + SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE + }, + ) } private const val SQLITE_OPEN_READONLY = 0x01 diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index beaf14e9..5c759511 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -9,9 +9,10 @@ public actual class DatabaseDriverFactory { internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = dbFilename - internal actual fun openConnection(path: String, openFlags: Int): SQLiteConnection { - return driver.open(path, openFlags) - } + internal actual fun openConnection( + path: String, + openFlags: Int, + ): SQLiteConnection = driver.open(path, openFlags) } public fun BundledSQLiteDriver.addPowerSyncExtension() { diff --git a/core/src/nativeMain/interop/sqlite3.h b/core/src/nativeMain/interop/sqlite3.h index 63ceb67f..7a6e0536 100644 --- a/core/src/nativeMain/interop/sqlite3.h +++ b/core/src/nativeMain/interop/sqlite3.h @@ -34,9 +34,14 @@ int sqlite3_load_extension( ); // Statements -int sqlite3_prepare_v3(sqlite3 *db, const char *zSql, int nByte, - unsigned int prepFlags, sqlite3_stmt **ppStmt, - const char **pzTail); +int sqlite3_prepare16_v3( + sqlite3 *db, /* Database handle */ + const void *zSql, /* SQL statement, UTF-16 encoded */ + int nByte, /* Maximum length of zSql in bytes. */ + unsigned int prepFlags, /* Zero or more SQLITE_PREPARE_ flags */ + sqlite3_stmt **ppStmt, /* OUT: Statement handle */ + const void **pzTail /* OUT: Pointer to unused portion of zSql */ +); int sqlite3_finalize(sqlite3_stmt *pStmt); int sqlite3_step(sqlite3_stmt *pStmt); int sqlite3_reset(sqlite3_stmt *pStmt); @@ -57,7 +62,7 @@ int sqlite3_bind_text16(sqlite3_stmt *pStmt, int index, char *data, void *sqlite3_column_blob(sqlite3_stmt *pStmt, int iCol); double sqlite3_column_double(sqlite3_stmt *pStmt, int iCol); int64_t sqlite3_column_int64(sqlite3_stmt *pStmt, int iCol); -char *sqlite3_column_text(sqlite3_stmt *pStmt, int iCol); +void *sqlite3_column_text16(sqlite3_stmt *pStmt, int iCol); int sqlite3_column_bytes(sqlite3_stmt *pStmt, int iCol); int sqlite3_column_bytes16(sqlite3_stmt *pStmt, int iCol); int sqlite3_column_type(sqlite3_stmt *pStmt, int iCol); diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt index 782cc21e..8793bfa6 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt @@ -4,15 +4,14 @@ import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteStatement import cnames.structs.sqlite3 import cnames.structs.sqlite3_stmt -import com.powersync.PowerSyncException import com.powersync.internal.sqlite3.sqlite3_close_v2 -import com.powersync.internal.sqlite3.sqlite3_get_autocommit -import com.powersync.internal.sqlite3.sqlite3_initialize -import com.powersync.internal.sqlite3.sqlite3_open_v2 -import com.powersync.internal.sqlite3.sqlite3_prepare_v3 import com.powersync.internal.sqlite3.sqlite3_db_config import com.powersync.internal.sqlite3.sqlite3_free +import com.powersync.internal.sqlite3.sqlite3_get_autocommit +import com.powersync.internal.sqlite3.sqlite3_initialize import com.powersync.internal.sqlite3.sqlite3_load_extension +import com.powersync.internal.sqlite3.sqlite3_open_v2 +import com.powersync.internal.sqlite3.sqlite3_prepare16_v3 import kotlinx.cinterop.ByteVar import kotlinx.cinterop.CPointer import kotlinx.cinterop.CPointerVar @@ -21,26 +20,32 @@ import kotlinx.cinterop.allocPointerTo import kotlinx.cinterop.cstr import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr -import kotlinx.cinterop.reinterpret import kotlinx.cinterop.toKStringFromUtf8 import kotlinx.cinterop.utf16 import kotlinx.cinterop.value -internal class Database(private val ptr: CPointer): SQLiteConnection { +internal class Database( + private val ptr: CPointer, +) : SQLiteConnection { override fun inTransaction(): Boolean { // We're in a transaction if autocommit is disabled return sqlite3_get_autocommit(ptr) == 0 } - override fun prepare(sql: String): SQLiteStatement = memScoped { - val stmtPtr = allocPointerTo() - val asUtf16 = sql.utf16 - sqlite3_prepare_v3(ptr, asUtf16.ptr.reinterpret(), asUtf16.size, 0u, stmtPtr.ptr, null).checkResult() + override fun prepare(sql: String): SQLiteStatement = + memScoped { + val stmtPtr = allocPointerTo() + val asUtf16 = sql.utf16 + sqlite3_prepare16_v3(ptr, asUtf16.ptr, asUtf16.size, 0u, stmtPtr.ptr, null) + .checkResult(sql) - Statement(sql, ptr, stmtPtr.value!!) - } + Statement(sql, ptr, stmtPtr.value!!) + } - fun loadExtension(filename: String, entrypoint: String) = memScoped { + fun loadExtension( + filename: String, + entrypoint: String, + ) = memScoped { val errorMessagePointer = alloc>() val resultCode = sqlite3_load_extension(ptr, filename, entrypoint, errorMessagePointer.ptr) @@ -58,32 +63,36 @@ internal class Database(private val ptr: CPointer): SQLiteConnection { sqlite3_close_v2(ptr) } - private fun Int.checkResult() { + private fun Int.checkResult(stmt: String? = null) { if (this != 0) { - throw PowerSyncException("SQLite error", SqliteException.createExceptionInDatabase(this, ptr)) + throw SqliteException.createExceptionInDatabase(this, ptr, stmt) } } companion object { - fun open(path: String, flags: Int): Database = memScoped { - var rc = sqlite3_initialize() - if (rc != 0) { - throw SqliteException.createExceptionOutsideOfDatabase(rc) - } + fun open( + path: String, + flags: Int, + ): Database = + memScoped { + var rc = sqlite3_initialize() + if (rc != 0) { + throw SqliteException.createExceptionOutsideOfDatabase(rc) + } - val encodedPath = path.cstr.getPointer(this) - val ptr = allocPointerTo() - rc = sqlite3_open_v2(encodedPath, ptr.ptr, flags, null) - if (rc != 0) { - throw SqliteException.createExceptionOutsideOfDatabase(rc) - } + val encodedPath = path.cstr.getPointer(this) + val ptr = allocPointerTo() + rc = sqlite3_open_v2(encodedPath, ptr.ptr, flags, null) + if (rc != 0) { + throw SqliteException.createExceptionOutsideOfDatabase(rc) + } - val db = ptr.value!! - // Enable extensions via the C API - sqlite3_db_config(db, DBCONFIG_ENABLE_LOAD_EXTENSION, 1, 0) + val db = ptr.value!! + // Enable extensions via the C API + sqlite3_db_config(db, DBCONFIG_ENABLE_LOAD_EXTENSION, 1, 0) - Database(db) - } + Database(db) + } private const val DBCONFIG_ENABLE_LOAD_EXTENSION = 1005 } diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt index 12046e82..be4f6091 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt @@ -14,18 +14,17 @@ internal class SqliteException( val extendedErrorCode: Int? = null, val offset: Int? = null, val dbMessage: String? = null, - val sql: String? = null -): Exception(message) { - - override fun toString(): String { - return buildString { + val sql: String? = null, +) : Exception(message) { + override fun toString(): String = + buildString { append("SqliteException(") append(extendedErrorCode ?: code) append("): ") append(message) offset?.let { - append(" at offset") + append(" at offset ") append(it) } @@ -35,18 +34,19 @@ internal class SqliteException( } sql?.let { - append("for SQL: ") + append(" for SQL: ") append(it) } } - } companion object { - fun createExceptionOutsideOfDatabase(code: Int): SqliteException { - return SqliteException(code, sqlite3_errstr(code)!!.toKStringFromUtf8()) - } + fun createExceptionOutsideOfDatabase(code: Int): SqliteException = SqliteException(code, sqlite3_errstr(code)!!.toKStringFromUtf8()) - fun createExceptionInDatabase(code: Int, db: CPointer, sql: String? = null): SqliteException { + fun createExceptionInDatabase( + code: Int, + db: CPointer, + sql: String? = null, + ): SqliteException { val extended = sqlite3_extended_errcode(db) val offset = sqlite3_error_offset(db).takeIf { it >= 0 } val dbMsg = sqlite3_errmsg(db)?.toKStringFromUtf8() diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt index a6024824..e778b4a3 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt @@ -11,11 +11,11 @@ import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.UShortVar import kotlinx.cinterop.addressOf -import kotlinx.cinterop.get import kotlinx.cinterop.memScoped import kotlinx.cinterop.readBytes import kotlinx.cinterop.reinterpret import kotlinx.cinterop.toCPointer +import kotlinx.cinterop.toKStringFromUtf16 import kotlinx.cinterop.toKStringFromUtf8 import kotlinx.cinterop.usePinned import kotlinx.cinterop.utf16 @@ -24,9 +24,12 @@ import kotlinx.cinterop.utf16 internal class Statement( private val sql: String, private val db: CPointer, - private val ptr: CPointer -): SQLiteStatement { - override fun bindBlob(index: Int, value: ByteArray) { + private val ptr: CPointer, +) : SQLiteStatement { + override fun bindBlob( + index: Int, + value: ByteArray, + ) { value.usePinned { pinned -> val valuePtr = pinned.addressOf(0) sqlite3_bind_blob64( @@ -34,23 +37,32 @@ internal class Statement( index, valuePtr, value.size.toULong(), - DESTRUCTOR_TRANSIENT - ) + DESTRUCTOR_TRANSIENT, + ).checkResult() } } - override fun bindDouble(index: Int, value: Double) { + override fun bindDouble( + index: Int, + value: Double, + ) { sqlite3_bind_double(ptr, index, value).checkResult() } - override fun bindLong(index: Int, value: Long) { + override fun bindLong( + index: Int, + value: Long, + ) { sqlite3_bind_int64(ptr, index, value).checkResult() } - override fun bindText(index: Int, value: String) { + override fun bindText( + index: Int, + value: String, + ) { memScoped { val utf16 = value.utf16 - sqlite3_bind_text16(ptr, index, utf16.ptr.reinterpret(), utf16.size, DESTRUCTOR_TRANSIENT) + sqlite3_bind_text16(ptr, index, utf16.ptr.reinterpret(), utf16.size - 1, DESTRUCTOR_TRANSIENT).checkResult() } } @@ -59,7 +71,7 @@ internal class Statement( } override fun getBlob(index: Int): ByteArray { - val len = sqlite3_column_bytes(ptr, index) + val len = sqlite3_column_bytes(ptr, index.columnIndex()) if (len == 0) { return byteArrayOf() } @@ -68,51 +80,33 @@ internal class Statement( return buf.reinterpret().readBytes(len) // Note: this copies } - override fun getDouble(index: Int): Double { - return sqlite3_column_double(ptr, index) - } + override fun getDouble(index: Int): Double = sqlite3_column_double(ptr, index.columnIndex()) - override fun getLong(index: Int): Long { - return sqlite3_column_int64(ptr, index) - } + override fun getLong(index: Int): Long = sqlite3_column_int64(ptr, index.columnIndex()) override fun getText(index: Int): String { - val len = sqlite3_column_bytes16(ptr, index) - if (len == 0) { + val value = sqlite3_column_text16(ptr, index.columnIndex()) + if (value == null) { return "" } - val utf16Ptr: CPointer = sqlite3_column_text( - ptr, - index - )!!.reinterpret() - val characters = CharArray(len) { utf16Ptr[it].toInt().toChar() } - return characters.concatToString() + return value.reinterpret().toKStringFromUtf16() } - override fun isNull(index: Int): Boolean { - return sqlite3_column_type(ptr, index) == SQLITE_NULL - } + override fun isNull(index: Int): Boolean = sqlite3_column_type(ptr, index.columnIndex()) == SQLITE_NULL - override fun getColumnCount(): Int { - return sqlite3_column_count(ptr) - } + override fun getColumnCount(): Int = sqlite3_column_count(ptr) - override fun getColumnName(index: Int): String { - return sqlite3_column_name(ptr, index)!!.toKStringFromUtf8() - } + override fun getColumnName(index: Int): String = sqlite3_column_name(ptr, index.columnIndex())!!.toKStringFromUtf8() - override fun getColumnType(index: Int): Int { - return sqlite3_column_type(ptr, index) - } + override fun getColumnType(index: Int): Int = sqlite3_column_type(ptr, index.columnIndex()) - override fun step(): Boolean { - return when (val rc = sqlite3_step(ptr)) { + override fun step(): Boolean = + when (val rc = sqlite3_step(ptr)) { SQLITE_ROW -> true SQLITE_DONE -> false else -> throwException(rc) } - } override fun reset() { sqlite3_reset(ptr).checkResult() @@ -132,11 +126,17 @@ internal class Statement( } } - private fun throwException(errorCode: Int): Nothing { - throw SqliteException.createExceptionInDatabase(errorCode, db, sql) + private fun Int.columnIndex(): Int { + if (this < 0 || this >= getColumnCount()) { + throw IllegalArgumentException("Invalid column index: $this") + } + + return this } - private companion object { + private fun throwException(errorCode: Int): Nothing = throw SqliteException.createExceptionInDatabase(errorCode, db, sql) + + internal companion object { const val SQLITE_INTEGER = 1 const val SQLITE_FLOAT = 2 const val SQLITE_TEXT = 3 diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index 8cdf0996..d92a9a82 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -8,7 +8,10 @@ import com.powersync.static.powersync_init_static public actual class DatabaseDriverFactory { internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) - internal actual fun openConnection(path: String, openFlags: Int): SQLiteConnection { + internal actual fun openConnection( + path: String, + openFlags: Int, + ): SQLiteConnection { didLoadExtension return Database.open(path, openFlags) } diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt new file mode 100644 index 00000000..e51df1bc --- /dev/null +++ b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt @@ -0,0 +1,114 @@ +package com.powersync.compile + +import kotlin.io.path.Path +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.provider.Provider +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.jetbrains.kotlin.konan.target.KonanTarget +import javax.inject.Inject +import kotlin.io.path.absolutePathString +import kotlin.io.path.name + +@CacheableTask +abstract class ClangCompile: DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val inputFile: RegularFileProperty + + @get:Input + abstract val konanTarget: Property + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.NONE) + abstract val include: DirectoryProperty + + @get:OutputFile + abstract val objectFile: RegularFileProperty + + @get:Inject + protected abstract val providers: ProviderFactory + + @get:Input + val xcodeInstallation: Provider get() = providers.exec { + executable("xcode-select") + args("-p") + }.standardOutput.asText + + @TaskAction + fun run() { + val target = requireNotNull(KonanTarget.predefinedTargets[konanTarget.get()]) + val xcodePath = xcodeInstallation.get().trim() + if (xcodePath.isEmpty()) { + throw GradleException("xcode-select was unable to resolve an XCode installation") + } + + val xcode = Path(xcodePath) + val toolchain = xcode.resolve("Toolchains/XcodeDefault.xctoolchain/usr/bin").absolutePathString() + + val (llvmTarget, sysRoot) = when (target) { + KonanTarget.IOS_X64 -> "x86_64-apple-ios12.0-simulator" to IOS_SIMULATOR_SDK + KonanTarget.IOS_ARM64 -> "arm64-apple-ios12.0" to IOS_SDK + KonanTarget.IOS_SIMULATOR_ARM64 -> "arm64-apple-ios14.0-simulator" to IOS_SIMULATOR_SDK + KonanTarget.MACOS_ARM64 -> "aarch64-apple-macos" to MACOS_SDK + KonanTarget.MACOS_X64 -> "x86_64-apple-macos" to MACOS_SDK + KonanTarget.WATCHOS_DEVICE_ARM64 -> "aarch64-apple-watchos" to WATCHOS_SDK + KonanTarget.WATCHOS_ARM32 -> "armv7k-apple-watchos" to WATCHOS_SDK + KonanTarget.WATCHOS_ARM64 -> "arm64_32-apple-watchos" to WATCHOS_SDK + KonanTarget.WATCHOS_SIMULATOR_ARM64 -> "aarch64-apple-watchos-simulator" to WATCHOS_SIMULATOR_SDK + KonanTarget.WATCHOS_X64 -> "x86_64-apple-watchos-simulator" to WATCHOS_SIMULATOR_SDK + KonanTarget.TVOS_ARM64 -> "aarch64-apple-tvos" to TVOS_SDK + KonanTarget.TVOS_X64 -> "x86_64-apple-tvos-simulator" to TVOS_SIMULATOR_SDK + KonanTarget.TVOS_SIMULATOR_ARM64 -> "aarch64-apple-tvos-simulator" to TVOS_SIMULATOR_SDK + else -> error("Unexpected target $target") + } + + val output = objectFile.get() + + providers.exec { + executable = "clang" + args( + "-B${toolchain}", + "-fno-stack-protector", + "-target", + llvmTarget, + "-isysroot", + xcode.resolve(sysRoot).absolutePathString(), + "-fPIC", + "--compile", + "-I${include.get().asFile.absolutePath}", + inputFile.get().asFile.absolutePath, + "-DHAVE_GETHOSTUUID=0", + "-DSQLITE_ENABLE_DBSTAT_VTAB", + "-DSQLITE_ENABLE_FTS5", + "-DSQLITE_ENABLE_RTREE", + "-O3", + "-o", + output.asFile.toPath().name, + ) + + workingDir = output.asFile.parentFile + }.result.get() + } + + companion object { + const val WATCHOS_SDK = "Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk" + const val WATCHOS_SIMULATOR_SDK = "Platforms/WatchSimulator.platform/Developer/SDKs/WatchSimulator.sdk/" + const val IOS_SDK = "Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk" + const val IOS_SIMULATOR_SDK = "Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk" + const val TVOS_SDK = "Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS.sdk" + const val TVOS_SIMULATOR_SDK = "Platforms/AppleTVSimulator.platform/Developer/SDKs/AppleTVSimulator.sdk" + const val MACOS_SDK = "Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/" + } +} diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateSqliteCInterop.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateSqliteCInterop.kt new file mode 100644 index 00000000..e2bcef63 --- /dev/null +++ b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateSqliteCInterop.kt @@ -0,0 +1,38 @@ +package com.powersync.compile + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ProjectLayout +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import javax.inject.Inject + +@DisableCachingByDefault(because = "not worth caching") +abstract class CreateSqliteCInterop: DefaultTask() { + @get:InputFile + abstract val archiveFile: RegularFileProperty + + @get:OutputFile + abstract val definitionFile: RegularFileProperty + + @get:Inject + abstract val layout: ProjectLayout + + @TaskAction + fun run() { + val archive = archiveFile.get().asFile + val parent = archive.parentFile + + definitionFile.get().asFile.writeText(""" + package = com.powersync.sqlite3 + + linkerOpts.linux_x64 = -lpthread -ldl + linkerOpts.macos_x64 = -lpthread -ldl + staticLibraries=${archive.name} + libraryPaths=${parent.relativeTo(layout.projectDirectory.asFile.canonicalFile)} + """.trimIndent(), + ) + } +} diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateStaticLibrary.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateStaticLibrary.kt new file mode 100644 index 00000000..58dfdba6 --- /dev/null +++ b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/CreateStaticLibrary.kt @@ -0,0 +1,37 @@ +package com.powersync.compile + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import javax.inject.Inject + +@CacheableTask +abstract class CreateStaticLibrary: DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val objects: ConfigurableFileCollection + + @get:OutputFile + abstract val staticLibrary: RegularFileProperty + + @get:Inject + abstract val providers: ProviderFactory + + @TaskAction + fun run() { + providers.exec { + executable = "ar" + args("rc", staticLibrary.get().asFile.absolutePath) + for (file in objects.files) { + args(file.absolutePath) + } + }.result.get() + } +} diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/UnzipSqlite.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/UnzipSqlite.kt new file mode 100644 index 00000000..65aa1157 --- /dev/null +++ b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/UnzipSqlite.kt @@ -0,0 +1,35 @@ +package com.powersync.compile + +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileTree +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Copy +import org.gradle.api.tasks.OutputDirectory + +/** + * A cacheable [Copy] task providing a typed provider for the output directory. + */ +@CacheableTask +abstract class UnzipSqlite: Copy() { + @get:OutputDirectory + abstract val destination: DirectoryProperty + + fun unzipSqlite(src: FileTree, dir: Provider) { + from( + src.matching { + include("*/sqlite3.*") + exclude { + it.isDirectory + } + eachFile { + this.path = this.name + } + }, + ) + + into(dir) + destination.set(dir) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 77b4160c..4855b5c7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,6 +30,7 @@ rootProject.name = "powersync-root" include(":core") include(":core-tests-android") include(":connectors:supabase") +include(":static-sqlite-driver") include(":PowerSyncKotlin") diff --git a/static-sqlite-driver/README.md b/static-sqlite-driver/README.md new file mode 100644 index 00000000..15ce2f7a --- /dev/null +++ b/static-sqlite-driver/README.md @@ -0,0 +1 @@ +This project builds a `.klib` linking sqlite3 statically, without containing other Kotlin sources. diff --git a/static-sqlite-driver/build.gradle.kts b/static-sqlite-driver/build.gradle.kts new file mode 100644 index 00000000..76e6cd0d --- /dev/null +++ b/static-sqlite-driver/build.gradle.kts @@ -0,0 +1,107 @@ +import com.powersync.compile.ClangCompile +import com.powersync.compile.CreateSqliteCInterop +import com.powersync.compile.CreateStaticLibrary +import com.powersync.compile.UnzipSqlite +import java.io.File +import com.powersync.plugins.sonatype.setupGithubRepository +import com.powersync.plugins.utils.powersyncTargets +import de.undercouch.gradle.tasks.download.Download +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.konan.target.HostManager + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.downloadPlugin) + alias(libs.plugins.kotlinter) + id("com.powersync.plugins.sonatype") +} + +val sqliteVersion = "3500300" +val sqliteReleaseYear = "2025" + +val downloadSQLiteSources by tasks.registering(Download::class) { + val zipFileName = "sqlite-amalgamation-$sqliteVersion.zip" + src("https://www.sqlite.org/$sqliteReleaseYear/$zipFileName") + dest(layout.buildDirectory.dir("downloads").map { it.file(zipFileName) }) + onlyIfNewer(true) + overwrite(false) +} + +val unzipSQLiteSources by tasks.registering(UnzipSqlite::class) { + val zip = downloadSQLiteSources.map { it.outputs.files.singleFile } + inputs.file(zip) + + unzipSqlite( + src = zipTree(zip), + dir = layout.buildDirectory.dir("downloads/sqlite3") + ) +} + +// Obtain host and platform manager from Kotlin multiplatform plugin. They're supposed to be +// internal, but it's very convenient to have them because they expose the necessary toolchains we +// use to compile SQLite for the platforms we need. +val hostManager = HostManager() + +fun compileSqlite(target: KotlinNativeTarget): TaskProvider { + val name = target.targetName + val outputDir = layout.buildDirectory.dir("c/$name") + + val sqlite3Obj = outputDir.map { it.file("sqlite3.o") } + val archive = outputDir.map { it.file("libsqlite3.a") } + + val compileSqlite = tasks.register("${name}CompileSqlite", ClangCompile::class) { + inputs.dir(unzipSQLiteSources.map { it.destination }) + + inputFile.set(unzipSQLiteSources.flatMap { it.destination.file("sqlite3.c") }) + konanTarget.set(target.konanTarget.name) + include.set(unzipSQLiteSources.flatMap { it.destination }) + objectFile.set(sqlite3Obj) + } + + val createStaticLibrary = tasks.register("${name}ArchiveSqlite", CreateStaticLibrary::class) { + inputs.file(compileSqlite.map { it.objectFile }) + objects.from(sqlite3Obj) + staticLibrary.set(archive) + } + + val buildCInteropDef = tasks.register("${name}CinteropSqlite", CreateSqliteCInterop::class) { + inputs.file(createStaticLibrary.map { it.staticLibrary }) + + archiveFile.set(archive) + definitionFile.fileProvider(archive.map { File(it.asFile.parentFile, "sqlite3.def") }) + } + + return buildCInteropDef +} + +kotlin { + // We use sqlite3-jdbc on JVM platforms instead + powersyncTargets(jvm=false) + + applyDefaultHierarchyTemplate() + explicitApi() + + sourceSets { + all { + languageSettings.apply { + optIn("kotlin.experimental.ExperimentalNativeApi") + optIn("kotlinx.cinterop.ExperimentalForeignApi") + optIn("kotlinx.cinterop.BetaInteropApi") + } + } + } + + targets.withType { + if (hostManager.isEnabled(konanTarget)) { + val compileSqlite3 = compileSqlite(this) + + compilations.named("main") { + cinterops.create("sqlite3") { + definitionFile.set(compileSqlite3.flatMap { it.definitionFile }) + } + } + } + } +} + +setupGithubRepository() diff --git a/static-sqlite-driver/gradle.properties b/static-sqlite-driver/gradle.properties new file mode 100644 index 00000000..d648100b --- /dev/null +++ b/static-sqlite-driver/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=static-sqlite-driver +POM_NAME=Statically linked SQLite +POM_DESCRIPTION=A Kotlin-multiplatform bundle containing a static library for SQLite without Kotlin code. diff --git a/static-sqlite-driver/src/commonMain/kotlin/com/powersync/sqlite3/StaticSqliteDriver.kt b/static-sqlite-driver/src/commonMain/kotlin/com/powersync/sqlite3/StaticSqliteDriver.kt new file mode 100644 index 00000000..51f02006 --- /dev/null +++ b/static-sqlite-driver/src/commonMain/kotlin/com/powersync/sqlite3/StaticSqliteDriver.kt @@ -0,0 +1,9 @@ +package com.powersync.sqlite3 + +/** + * An empty Kotlin object. + * + * This package needs to provide a source to be published correctly. The only purpose of this package is to provide + * build scripts linking SQLite statically however, so this empty object is defined for publishing only. + */ +public object StaticSqliteDriver From 8f5f8cd5972bf84c7d174f355d48a88172e7da0b Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 5 Sep 2025 14:20:44 +0200 Subject: [PATCH 15/28] Fix linter errors --- .../kotlin/com/powersync/sqlite/Statement.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt index e778b4a3..21500504 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt @@ -3,7 +3,23 @@ package com.powersync.sqlite import androidx.sqlite.SQLiteStatement import cnames.structs.sqlite3 import cnames.structs.sqlite3_stmt -import com.powersync.internal.sqlite3.* +import com.powersync.internal.sqlite3.sqlite3_bind_blob64 +import com.powersync.internal.sqlite3.sqlite3_bind_double +import com.powersync.internal.sqlite3.sqlite3_bind_int64 +import com.powersync.internal.sqlite3.sqlite3_bind_null +import com.powersync.internal.sqlite3.sqlite3_bind_text16 +import com.powersync.internal.sqlite3.sqlite3_clear_bindings +import com.powersync.internal.sqlite3.sqlite3_column_blob +import com.powersync.internal.sqlite3.sqlite3_column_bytes +import com.powersync.internal.sqlite3.sqlite3_column_count +import com.powersync.internal.sqlite3.sqlite3_column_double +import com.powersync.internal.sqlite3.sqlite3_column_int64 +import com.powersync.internal.sqlite3.sqlite3_column_name +import com.powersync.internal.sqlite3.sqlite3_column_text16 +import com.powersync.internal.sqlite3.sqlite3_column_type +import com.powersync.internal.sqlite3.sqlite3_finalize +import com.powersync.internal.sqlite3.sqlite3_reset +import com.powersync.internal.sqlite3.sqlite3_step import kotlinx.cinterop.ByteVar import kotlinx.cinterop.COpaquePointer import kotlinx.cinterop.CPointed From f41b0a4f9797293ca3ac2bee96086ce40552c264 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 5 Sep 2025 15:41:26 +0200 Subject: [PATCH 16/28] Fix Swift tests --- .../powersync/DatabaseDriverFactory.apple.kt | 5 +- .../DatabaseDriverFactory.appleNonWatchOs.kt | 3 +- .../com/powersync/sqlite/DatabaseTest.kt | 5 +- .../com/powersync/sqlite/StatementTest.kt | 7 +-- .../com/powersync/PowerSyncDatabaseFactory.kt | 17 ++++--- .../com/powersync/db/driver/LazyPool.kt | 30 +++++++++++ .../db/internal/InternalDatabaseImpl.kt | 11 ---- .../db/internal/PowerSyncTransaction.kt | 4 +- core/src/nativeMain/interop/sqlite3.h | 1 + .../kotlin/com/powersync/sqlite/Database.kt | 22 ++++++-- .../com/powersync/sqlite/SqliteException.kt | 50 ++++++------------- .../kotlin/com/powersync/sqlite/Statement.kt | 2 +- 12 files changed, 87 insertions(+), 70 deletions(-) create mode 100644 core/src/commonMain/kotlin/com/powersync/db/driver/LazyPool.kt diff --git a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt index acc25d9b..4149231f 100644 --- a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt +++ b/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt @@ -1,6 +1,7 @@ package com.powersync import kotlinx.cinterop.UnsafeNumber +import kotlinx.io.files.FileSystem import platform.Foundation.NSApplicationSupportDirectory import platform.Foundation.NSBundle import platform.Foundation.NSFileManager @@ -10,7 +11,7 @@ import kotlin.getValue @OptIn(UnsafeNumber::class) internal fun appleDefaultDatabasePath(dbFilename: String): 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 + // 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 @@ -22,7 +23,7 @@ internal fun appleDefaultDatabasePath(dbFilename: String): String { fileManager.createDirectoryAtPath(databaseDirectory, true, null, null) }; // Create folder - return databaseDirectory + return "$databaseDirectory/$dbFilename" } internal val powerSyncExtensionPath: String by lazy { diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index 5c7b32d3..b2e8a15e 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -2,7 +2,6 @@ package com.powersync import androidx.sqlite.SQLiteConnection import com.powersync.sqlite.Database -import com.powersync.sqlite.SqliteException @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) public actual class DatabaseDriverFactory { @@ -15,7 +14,7 @@ public actual class DatabaseDriverFactory { val db = Database.open(path, openFlags) try { db.loadExtension(powerSyncExtensionPath, "sqlite3_powersync_init") - } catch (e: SqliteException) { + } catch (e: PowerSyncException) { db.close() throw e } diff --git a/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt b/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt index ca7107f1..338c7ba4 100644 --- a/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt +++ b/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt @@ -2,6 +2,7 @@ package com.powersync.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.execSQL +import com.powersync.PowerSyncException import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import kotlin.test.Test @@ -22,9 +23,9 @@ class DatabaseTest { @Test fun syntaxError() = inMemoryDatabase().use { - val exception = shouldThrow { it.execSQL("bad syntax") } + val exception = shouldThrow { it.execSQL("bad syntax") } - exception.toString() shouldBe "SqliteException(1): SQL logic error at offset 0, near \"bad\": syntax error for SQL: bad syntax" + exception.message shouldBe "SqliteException(1): SQL logic error at offset 0, near \"bad\": syntax error for SQL: bad syntax" Unit } diff --git a/core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt b/core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt index 0c01c6fc..eb2f798d 100644 --- a/core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt +++ b/core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt @@ -1,6 +1,7 @@ package com.powersync.sqlite import androidx.sqlite.SQLiteConnection +import com.powersync.PowerSyncException import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import kotlin.test.Test @@ -28,16 +29,16 @@ class StatementTest { fun testBindOutOfBounds() = inMemoryDatabase().use { db -> db.prepare("SELECT ?").use { stmt -> - shouldThrow { + shouldThrow { stmt.bindText(-1, "foo") } - shouldThrow { + shouldThrow { stmt.bindText(0, "foo") } stmt.bindText(1, "foo") - shouldThrow { + shouldThrow { stmt.bindText(2, "foo") } } diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt index f8a3b217..6ac78706 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt @@ -5,6 +5,7 @@ import co.touchlab.skie.configuration.annotations.DefaultArgumentInterop import com.powersync.db.ActiveDatabaseGroup import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.driver.InternalConnectionPool +import com.powersync.db.driver.LazyPool import com.powersync.db.schema.Schema import com.powersync.utils.generateLogger import kotlinx.coroutines.CoroutineScope @@ -55,13 +56,15 @@ internal fun createPowerSyncDatabaseImpl( val activeDatabaseGroup = ActiveDatabaseGroup.referenceDatabase(logger, identifier) val pool = - InternalConnectionPool( - factory, - scope, - dbFilename, - dbDirectory, - activeDatabaseGroup.first.group.writeLockMutex, - ) + LazyPool { + InternalConnectionPool( + factory, + scope, + dbFilename, + dbDirectory, + activeDatabaseGroup.first.group.writeLockMutex, + ) + } return PowerSyncDatabase.opened( pool, diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/LazyPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/LazyPool.kt new file mode 100644 index 00000000..53f23c34 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/LazyPool.kt @@ -0,0 +1,30 @@ +package com.powersync.db.driver + +import com.powersync.ExperimentalPowerSyncAPI +import kotlinx.coroutines.flow.SharedFlow + +/** + * A [SQLiteConnectionPool] implemented by constructing an inner pool on first access. + * + * This allows [InternalConnectionPool] to construct connections immediately (which potentially + * throws an exception that we want to report when the SDK is actually used instead of when it's + * first constructed). + */ +@OptIn(ExperimentalPowerSyncAPI::class) +internal class LazyPool( + openInner: () -> SQLiteConnectionPool, +) : SQLiteConnectionPool { + private val pool by lazy(openInner) + + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T = pool.read(callback) + + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T = pool.write(callback) + + override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) = + pool.withAllConnections(action) + + override val updates: SharedFlow> + get() = pool.updates + + override suspend fun close() = pool.close() +} 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 d0d22b15..0e902533 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -210,17 +210,6 @@ internal class InternalDatabaseImpl( // Register callback for table updates on a specific table override fun updatesOnTables(): SharedFlow> = pool.updates - // Unfortunately Errors can't be thrown from Swift SDK callbacks. - // These are currently returned and should be thrown here. - private inline fun catchSwiftExceptions(action: () -> R): R { - val result = action() - - if (result is PowerSyncException) { - throw result - } - return result - } - private suspend fun getSourceTables( sql: String, parameters: List?, 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 1b0fcac5..3cccaab9 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt @@ -59,16 +59,14 @@ internal class PowerSyncTransactionImpl( @ExperimentalPowerSyncAPI internal suspend fun SQLiteConnectionLease.runTransaction(cb: suspend (PowerSyncTransaction) -> T): T { execSQL("BEGIN") - var didComplete = false return try { val result = cb(PowerSyncTransactionImpl(this)) - didComplete = true check(isInTransaction()) execSQL("COMMIT") result } catch (e: Throwable) { - if (!didComplete && isInTransaction()) { + if (isInTransaction()) { execSQL("ROLLBACK") } diff --git a/core/src/nativeMain/interop/sqlite3.h b/core/src/nativeMain/interop/sqlite3.h index 7a6e0536..52447d02 100644 --- a/core/src/nativeMain/interop/sqlite3.h +++ b/core/src/nativeMain/interop/sqlite3.h @@ -32,6 +32,7 @@ int sqlite3_load_extension( const char *zProc, /* Entry point. Derived from zFile if 0 */ char **pzErrMsg /* Put error message here if not 0 */ ); +int sqlite3_extended_result_codes(sqlite3*, int onoff); // Statements int sqlite3_prepare16_v3( diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt index 8793bfa6..a08bf52a 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt @@ -4,8 +4,10 @@ import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteStatement import cnames.structs.sqlite3 import cnames.structs.sqlite3_stmt +import com.powersync.PowerSyncException import com.powersync.internal.sqlite3.sqlite3_close_v2 import com.powersync.internal.sqlite3.sqlite3_db_config +import com.powersync.internal.sqlite3.sqlite3_extended_result_codes import com.powersync.internal.sqlite3.sqlite3_free import com.powersync.internal.sqlite3.sqlite3_get_autocommit import com.powersync.internal.sqlite3.sqlite3_initialize @@ -24,6 +26,14 @@ import kotlinx.cinterop.toKStringFromUtf8 import kotlinx.cinterop.utf16 import kotlinx.cinterop.value +/** + * A simple implementation of the [SQLiteConnection] interface backed by a synchronous `sqlite3*` + * database pointer and the SQLite C APIs called via cinterop. + * + * Multiple instances of this class are bundled into an + * [com.powersync.db.driver.InternalConnectionPool] and called from [kotlinx.coroutines.Dispatchers.IO] + * to make these APIs asynchronous. + */ internal class Database( private val ptr: CPointer, ) : SQLiteConnection { @@ -55,7 +65,7 @@ internal class Database( sqlite3_free(errorMessagePointer.value) } - throw SqliteException(resultCode, errorMessage ?: "unknown error") + throw PowerSyncException("Could not load extension ($resultCode): ${errorMessage ?: "unknown error"}", null) } } @@ -65,7 +75,7 @@ internal class Database( private fun Int.checkResult(stmt: String? = null) { if (this != 0) { - throw SqliteException.createExceptionInDatabase(this, ptr, stmt) + throw createExceptionInDatabase(ptr, stmt) } } @@ -77,17 +87,21 @@ internal class Database( memScoped { var rc = sqlite3_initialize() if (rc != 0) { - throw SqliteException.createExceptionOutsideOfDatabase(rc) + throw PowerSyncException("sqlite3_initialize() failed", null) } val encodedPath = path.cstr.getPointer(this) val ptr = allocPointerTo() rc = sqlite3_open_v2(encodedPath, ptr.ptr, flags, null) if (rc != 0) { - throw SqliteException.createExceptionOutsideOfDatabase(rc) + throw PowerSyncException("Could not open database $path with $flags", null) } val db = ptr.value!! + + // Enable extended error codes. + sqlite3_extended_result_codes(db, 1) + // Enable extensions via the C API sqlite3_db_config(db, DBCONFIG_ENABLE_LOAD_EXTENSION, 1, 0) diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt index be4f6091..94905454 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt @@ -1,6 +1,7 @@ package com.powersync.sqlite import cnames.structs.sqlite3 +import com.powersync.PowerSyncException import com.powersync.internal.sqlite3.sqlite3_errmsg import com.powersync.internal.sqlite3.sqlite3_error_offset import com.powersync.internal.sqlite3.sqlite3_errstr @@ -8,27 +9,28 @@ import com.powersync.internal.sqlite3.sqlite3_extended_errcode import kotlinx.cinterop.CPointer import kotlinx.cinterop.toKStringFromUtf8 -internal class SqliteException( - val code: Int, - message: String, - val extendedErrorCode: Int? = null, - val offset: Int? = null, - val dbMessage: String? = null, - val sql: String? = null, -) : Exception(message) { - override fun toString(): String = +internal fun createExceptionInDatabase( + db: CPointer, + sql: String? = null, +): PowerSyncException { + val extended = sqlite3_extended_errcode(db) + val offset = sqlite3_error_offset(db).takeIf { it >= 0 } + val dbMsg = sqlite3_errmsg(db)?.toKStringFromUtf8() + val errStr = sqlite3_errstr(extended)!!.toKStringFromUtf8() + + val message = buildString { append("SqliteException(") - append(extendedErrorCode ?: code) + append(extended) append("): ") - append(message) + append(errStr) offset?.let { append(" at offset ") append(it) } - dbMessage?.let { + dbMsg?.let { append(", ") append(it) } @@ -39,27 +41,5 @@ internal class SqliteException( } } - companion object { - fun createExceptionOutsideOfDatabase(code: Int): SqliteException = SqliteException(code, sqlite3_errstr(code)!!.toKStringFromUtf8()) - - fun createExceptionInDatabase( - code: Int, - db: CPointer, - sql: String? = null, - ): SqliteException { - val extended = sqlite3_extended_errcode(db) - val offset = sqlite3_error_offset(db).takeIf { it >= 0 } - val dbMsg = sqlite3_errmsg(db)?.toKStringFromUtf8() - val errStr = sqlite3_errstr(extended)!!.toKStringFromUtf8() - - return SqliteException( - code, - errStr, - extended, - offset, - dbMsg, - sql, - ) - } - } + return PowerSyncException(message, null) } diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt index 21500504..4d7c4887 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt @@ -150,7 +150,7 @@ internal class Statement( return this } - private fun throwException(errorCode: Int): Nothing = throw SqliteException.createExceptionInDatabase(errorCode, db, sql) + private fun throwException(errorCode: Int): Nothing = throw createExceptionInDatabase(db, sql) internal companion object { const val SQLITE_INTEGER = 1 From 51521d8855c465f370917420fc35f05e791b8c3d Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 5 Sep 2025 15:54:42 +0200 Subject: [PATCH 17/28] Delete proguard rules --- core/proguard-rules.pro | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 core/proguard-rules.pro diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro deleted file mode 100644 index 60e7ccdf..00000000 --- a/core/proguard-rules.pro +++ /dev/null @@ -1,13 +0,0 @@ -# If the app calls the JNI method to initialize driver bindings, keep that method -# (so that it can be linked through JNI) and the other methods called from native -# code. --if class com.powersync.DatabaseDriverFactory { - private void setupSqliteBinding(); -} --keep class com.powersync.DatabaseDriverFactory { - private void setupSqliteBinding(); - private void onTableUpdate(java.lang.String); - private void onTransactionCommit(boolean); -} --keep class org.sqlite.** { *; } --dontwarn java.sql.JDBCType \ No newline at end of file From fd04adce857ed6401cf5893a3c5748461ff12e09 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Sun, 7 Sep 2025 09:27:45 -0600 Subject: [PATCH 18/28] grdb drivers --- .../powersync/SwiftSQLiteConnectionPool.kt | 141 ++++++++++++++++++ .../com/powersync/db/ActiveInstanceStore.kt | 12 +- .../kotlin/com/powersync/sqlite/Database.kt | 33 ++-- 3 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt new file mode 100644 index 00000000..40935c5f --- /dev/null +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt @@ -0,0 +1,141 @@ +package com.powersync + +import androidx.sqlite.SQLiteStatement +import cnames.structs.sqlite3 +import co.touchlab.kermit.Logger +import com.powersync.db.ActiveDatabaseGroup +import com.powersync.db.driver.SQLiteConnectionLease +import com.powersync.db.driver.SQLiteConnectionPool +import com.powersync.db.schema.Schema +import com.powersync.sqlite.Database +import io.ktor.utils.io.CancellationException +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.runBlocking + +@OptIn(ExperimentalPowerSyncAPI::class) +internal class RawConnectionLease + @OptIn(ExperimentalForeignApi::class) + constructor( + connectionPointer: CPointer, + ) : SQLiteConnectionLease { + private var isCompleted = false + + @OptIn(ExperimentalForeignApi::class) + private var db = Database(connectionPointer) + + private fun checkNotCompleted() { + check(!isCompleted) { "Connection lease already closed" } + } + + override suspend fun isInTransaction(): Boolean = isInTransactionSync() + + override fun isInTransactionSync(): Boolean { + checkNotCompleted() + return db.inTransaction() + } + + override suspend fun usePrepared( + sql: String, + block: (SQLiteStatement) -> R, + ): R = usePreparedSync(sql, block) + + override fun usePreparedSync( + sql: String, + block: (SQLiteStatement) -> R, + ): R { + checkNotCompleted() + return db.prepare(sql).use(block) + } + } + +/** + * We only allow synchronous callbacks on the Swift side for leased READ/WRITE connections. + * We also get a SQLite connection pointer (sqlite3*) from Swift side. which is used in a [Database] + */ + +public interface SwiftPoolAdapter { + @OptIn(ExperimentalForeignApi::class) + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun leaseRead(callback: (CPointer) -> Unit) + + @OptIn(ExperimentalForeignApi::class) + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun leaseWrite(callback: (CPointer) -> Unit) + + public suspend fun closePool() +} + +@OptIn(ExperimentalPowerSyncAPI::class) +public open class SwiftSQLiteConnectionPool + @OptIn(ExperimentalForeignApi::class) + constructor( + private val adapter: SwiftPoolAdapter, + ) : SQLiteConnectionPool { + private val _updates = MutableSharedFlow>(replay = 0) + override val updates: SharedFlow> get() = _updates + + public fun pushUpdate(update: Set) { + _updates.tryEmit(update) + } + + @OptIn(ExperimentalForeignApi::class) + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { + var result: T? = null + adapter.leaseRead { + /** + * For GRDB, this should be running inside the callback + * ```swift + * db.write { + * // should be here + * } + * ``` + */ + val lease = RawConnectionLease(it) + runBlocking { + result = callback(lease) + } + } + return result as T + } + + @OptIn(ExperimentalForeignApi::class) + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { + var result: T? = null + adapter.leaseRead { + val lease = RawConnectionLease(it) + runBlocking { + result = callback(lease) + } + } + return result as T + } + + override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { + } + + override suspend fun close() { + adapter.closePool() + } + } + +@OptIn(ExperimentalPowerSyncAPI::class, DelicateCoroutinesApi::class) +public fun openPowerSyncWithPool( + pool: SQLiteConnectionPool, + identifier: String, + schema: Schema, + logger: Logger, +): PowerSyncDatabase { + val activeDatabaseGroup = ActiveDatabaseGroup.referenceDatabase(logger, identifier) + return PowerSyncDatabase.opened( + pool = pool, + scope = GlobalScope, + schema = schema, + group = activeDatabaseGroup, + logger = logger, + ) +} diff --git a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt index 29f7322c..006712f3 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt @@ -20,15 +20,15 @@ internal expect fun disposeWhenDeallocated(resource: ActiveDatabaseResource): An * duplicate resources being used. For this reason, each active database group has a coroutine mutex guarding the * sync job. */ -internal class ActiveDatabaseGroup( - val identifier: String, +public class ActiveDatabaseGroup( + public val identifier: String, private val collection: GroupsCollection, ) { internal var refCount = 0 // Guarded by companion object internal val syncMutex = Mutex() internal val writeLockMutex = Mutex() - fun removeUsage() { + internal fun removeUsage() { collection.synchronize { if (--refCount == 0) { collection.allGroups.remove(this) @@ -36,7 +36,7 @@ internal class ActiveDatabaseGroup( } } - internal open class GroupsCollection : Synchronizable() { + public open class GroupsCollection : Synchronizable() { internal val allGroups = mutableListOf() private fun findGroup( @@ -61,7 +61,7 @@ internal class ActiveDatabaseGroup( resolvedGroup } - internal fun referenceDatabase( + public fun referenceDatabase( warnOnDuplicate: Logger, identifier: String, ): Pair { @@ -72,7 +72,7 @@ internal class ActiveDatabaseGroup( } } - companion object : GroupsCollection() { + public companion object : GroupsCollection() { internal val multipleInstancesMessage = """ Multiple PowerSync instances for the same database have been detected. diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt index a08bf52a..5d80d6b3 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt @@ -34,7 +34,7 @@ import kotlinx.cinterop.value * [com.powersync.db.driver.InternalConnectionPool] and called from [kotlinx.coroutines.Dispatchers.IO] * to make these APIs asynchronous. */ -internal class Database( +public class Database( private val ptr: CPointer, ) : SQLiteConnection { override fun inTransaction(): Boolean { @@ -52,22 +52,27 @@ internal class Database( Statement(sql, ptr, stmtPtr.value!!) } - fun loadExtension( + public fun loadExtension( filename: String, entrypoint: String, - ) = memScoped { - val errorMessagePointer = alloc>() - val resultCode = sqlite3_load_extension(ptr, filename, entrypoint, errorMessagePointer.ptr) - - if (resultCode != 0) { - val errorMessage = errorMessagePointer.value?.toKStringFromUtf8() - if (errorMessage != null) { - sqlite3_free(errorMessagePointer.value) - } + ): Unit = + memScoped { + val errorMessagePointer = alloc>() + val resultCode = + sqlite3_load_extension(ptr, filename, entrypoint, errorMessagePointer.ptr) + + if (resultCode != 0) { + val errorMessage = errorMessagePointer.value?.toKStringFromUtf8() + if (errorMessage != null) { + sqlite3_free(errorMessagePointer.value) + } - throw PowerSyncException("Could not load extension ($resultCode): ${errorMessage ?: "unknown error"}", null) + throw PowerSyncException( + "Could not load extension ($resultCode): ${errorMessage ?: "unknown error"}", + null, + ) + } } - } override fun close() { sqlite3_close_v2(ptr) @@ -79,7 +84,7 @@ internal class Database( } } - companion object { + internal companion object { fun open( path: String, flags: Int, From b32f7bfa445b28e1cd927eb6adc7619c5f1cd21f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Sun, 7 Sep 2025 17:51:49 -0600 Subject: [PATCH 19/28] wip: lease all connections --- .../com/powersync/SwiftSQLiteConnectionPool.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt index 40935c5f..ab58d842 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt @@ -67,6 +67,10 @@ public interface SwiftPoolAdapter { @Throws(PowerSyncException::class, CancellationException::class) public suspend fun leaseWrite(callback: (CPointer) -> Unit) + @OptIn(ExperimentalForeignApi::class) + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun leaseAll(callback: (CPointer, List>) -> Unit) + public suspend fun closePool() } @@ -106,7 +110,7 @@ public open class SwiftSQLiteConnectionPool @OptIn(ExperimentalForeignApi::class) override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { var result: T? = null - adapter.leaseRead { + adapter.leaseWrite { val lease = RawConnectionLease(it) runBlocking { result = callback(lease) @@ -115,7 +119,13 @@ public open class SwiftSQLiteConnectionPool return result as T } + @OptIn(ExperimentalForeignApi::class) override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { + adapter.leaseAll { writerPtr, readerPtrs -> + runBlocking { + action(RawConnectionLease(writerPtr), readerPtrs.map { RawConnectionLease(it) }) + } + } } override suspend fun close() { From 9008648f8dc7352fe7ef700e1ab03a61cc9de438 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Sun, 7 Sep 2025 18:21:06 -0600 Subject: [PATCH 20/28] revert databasegroup changes. --- .../com/powersync/SwiftSQLiteConnectionPool.kt | 4 ++-- .../kotlin/com/powersync/db/ActiveInstanceStore.kt | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt index ab58d842..0ab72465 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt @@ -3,7 +3,7 @@ package com.powersync import androidx.sqlite.SQLiteStatement import cnames.structs.sqlite3 import co.touchlab.kermit.Logger -import com.powersync.db.ActiveDatabaseGroup +import com.powersync.PowerSyncDatabase.Companion.databaseGroup import com.powersync.db.driver.SQLiteConnectionLease import com.powersync.db.driver.SQLiteConnectionPool import com.powersync.db.schema.Schema @@ -140,7 +140,7 @@ public fun openPowerSyncWithPool( schema: Schema, logger: Logger, ): PowerSyncDatabase { - val activeDatabaseGroup = ActiveDatabaseGroup.referenceDatabase(logger, identifier) + val activeDatabaseGroup = databaseGroup(logger, identifier) return PowerSyncDatabase.opened( pool = pool, scope = GlobalScope, diff --git a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt index 006712f3..29f7322c 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt @@ -20,15 +20,15 @@ internal expect fun disposeWhenDeallocated(resource: ActiveDatabaseResource): An * duplicate resources being used. For this reason, each active database group has a coroutine mutex guarding the * sync job. */ -public class ActiveDatabaseGroup( - public val identifier: String, +internal class ActiveDatabaseGroup( + val identifier: String, private val collection: GroupsCollection, ) { internal var refCount = 0 // Guarded by companion object internal val syncMutex = Mutex() internal val writeLockMutex = Mutex() - internal fun removeUsage() { + fun removeUsage() { collection.synchronize { if (--refCount == 0) { collection.allGroups.remove(this) @@ -36,7 +36,7 @@ public class ActiveDatabaseGroup( } } - public open class GroupsCollection : Synchronizable() { + internal open class GroupsCollection : Synchronizable() { internal val allGroups = mutableListOf() private fun findGroup( @@ -61,7 +61,7 @@ public class ActiveDatabaseGroup( resolvedGroup } - public fun referenceDatabase( + internal fun referenceDatabase( warnOnDuplicate: Logger, identifier: String, ): Pair { @@ -72,7 +72,7 @@ public class ActiveDatabaseGroup( } } - public companion object : GroupsCollection() { + companion object : GroupsCollection() { internal val multipleInstancesMessage = """ Multiple PowerSync instances for the same database have been detected. From a92930a45820cbe98b20c845ca8184bf0ce855e1 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 16 Sep 2025 20:21:14 +0200 Subject: [PATCH 21/28] update after merging --- .../powersync/SwiftSQLiteConnectionPool.kt | 9 ++---- .../kotlin/com/powersync/sqlite/Database.kt | 28 +++++++++++-------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt index 0ab72465..84e36f5a 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt @@ -3,7 +3,6 @@ package com.powersync import androidx.sqlite.SQLiteStatement import cnames.structs.sqlite3 import co.touchlab.kermit.Logger -import com.powersync.PowerSyncDatabase.Companion.databaseGroup import com.powersync.db.driver.SQLiteConnectionLease import com.powersync.db.driver.SQLiteConnectionPool import com.powersync.db.schema.Schema @@ -139,13 +138,11 @@ public fun openPowerSyncWithPool( identifier: String, schema: Schema, logger: Logger, -): PowerSyncDatabase { - val activeDatabaseGroup = databaseGroup(logger, identifier) - return PowerSyncDatabase.opened( +): PowerSyncDatabase = + PowerSyncDatabase.opened( pool = pool, scope = GlobalScope, schema = schema, - group = activeDatabaseGroup, + identifier = identifier, logger = logger, ) -} diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt index c21a8c8e..6931b7c4 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt @@ -52,22 +52,26 @@ public class Database( Statement(sql, ptr, stmtPtr.value!!) } - fun loadExtension( + public fun loadExtension( filename: String, entrypoint: String, - ) = memScoped { - val errorMessagePointer = alloc>() - val resultCode = sqlite3_load_extension(ptr, filename, entrypoint, errorMessagePointer.ptr) - - if (resultCode != 0) { - val errorMessage = errorMessagePointer.value?.toKStringFromUtf8() - if (errorMessage != null) { - sqlite3_free(errorMessagePointer.value) - } + ): Unit = + memScoped { + val errorMessagePointer = alloc>() + val resultCode = sqlite3_load_extension(ptr, filename, entrypoint, errorMessagePointer.ptr) + + if (resultCode != 0) { + val errorMessage = errorMessagePointer.value?.toKStringFromUtf8() + if (errorMessage != null) { + sqlite3_free(errorMessagePointer.value) + } - throw PowerSyncException("Could not load extension ($resultCode): ${errorMessage ?: "unknown error"}", null) + throw PowerSyncException( + "Could not load extension ($resultCode): ${errorMessage ?: "unknown error"}", + null, + ) + } } - } override fun close() { sqlite3_close_v2(ptr) From ef4160c635ae7ebeec5d00d015df494fb02f32b8 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 16 Sep 2025 20:26:09 +0200 Subject: [PATCH 22/28] revert test change --- .../kotlin/com/powersync/db/ActiveInstanceStore.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt index 29f7322c..69528a79 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt @@ -82,12 +82,12 @@ internal class ActiveDatabaseGroup( } } -public class ActiveDatabaseResource internal constructor( - internal val group: ActiveDatabaseGroup, +internal class ActiveDatabaseResource constructor( + val group: ActiveDatabaseGroup, ) { - internal val disposed = AtomicBoolean(false) + val disposed = AtomicBoolean(false) - internal fun dispose() { + fun dispose() { if (disposed.compareAndSet(false, true)) { group.removeUsage() } From c0bdde90ef114853db7567bbd12a48f135f6913d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 23 Sep 2025 17:49:57 +0200 Subject: [PATCH 23/28] improve error handling --- .../powersync/SwiftSQLiteConnectionPool.kt | 86 +++++++++++++------ 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt index 84e36f5a..3af4b08f 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt @@ -5,6 +5,7 @@ import cnames.structs.sqlite3 import co.touchlab.kermit.Logger import com.powersync.db.driver.SQLiteConnectionLease import com.powersync.db.driver.SQLiteConnectionPool +import com.powersync.db.runWrapped import com.powersync.db.schema.Schema import com.powersync.sqlite.Database import io.ktor.utils.io.CancellationException @@ -20,12 +21,12 @@ import kotlinx.coroutines.runBlocking internal class RawConnectionLease @OptIn(ExperimentalForeignApi::class) constructor( - connectionPointer: CPointer, + private val lease: SwiftLeaseAdapter, ) : SQLiteConnectionLease { private var isCompleted = false @OptIn(ExperimentalForeignApi::class) - private var db = Database(connectionPointer) + private var db = Database(lease.pointer) private fun checkNotCompleted() { check(!isCompleted) { "Connection lease already closed" } @@ -52,6 +53,24 @@ internal class RawConnectionLease } } +public fun interface LeaseCallback { + @Throws(PowerSyncException::class, CancellationException::class) + public fun execute(lease: SwiftLeaseAdapter) +} + +public fun interface AllLeaseCallback { + @Throws(PowerSyncException::class, CancellationException::class) + public fun execute( + writeLease: SwiftLeaseAdapter, + readLeases: List, + ) +} + +public interface SwiftLeaseAdapter { + @OptIn(ExperimentalForeignApi::class) + public val pointer: CPointer +} + /** * We only allow synchronous callbacks on the Swift side for leased READ/WRITE connections. * We also get a SQLite connection pointer (sqlite3*) from Swift side. which is used in a [Database] @@ -60,15 +79,17 @@ internal class RawConnectionLease public interface SwiftPoolAdapter { @OptIn(ExperimentalForeignApi::class) @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun leaseRead(callback: (CPointer) -> Unit) + public suspend fun leaseRead(callback: LeaseCallback) @OptIn(ExperimentalForeignApi::class) @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun leaseWrite(callback: (CPointer) -> Unit) + public suspend fun leaseWrite(callback: LeaseCallback) @OptIn(ExperimentalForeignApi::class) @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun leaseAll(callback: (CPointer, List>) -> Unit) + public suspend fun leaseAll(callback: AllLeaseCallback) + + public fun linkUpdates(callback: suspend (Set) -> Unit) public suspend fun closePool() } @@ -82,25 +103,30 @@ public open class SwiftSQLiteConnectionPool private val _updates = MutableSharedFlow>(replay = 0) override val updates: SharedFlow> get() = _updates - public fun pushUpdate(update: Set) { - _updates.tryEmit(update) + init { + adapter.linkUpdates { tables -> + _updates.emit(tables) + } } @OptIn(ExperimentalForeignApi::class) override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { var result: T? = null adapter.leaseRead { - /** - * For GRDB, this should be running inside the callback - * ```swift - * db.write { - * // should be here - * } - * ``` - */ - val lease = RawConnectionLease(it) - runBlocking { - result = callback(lease) + runWrapped { + /** + * For GRDB, this should be running inside the callback + * ```swift + * db.write { + * // should be here + * } + * ``` + */ + val lease = + RawConnectionLease(it) + runBlocking { + result = callback(lease) + } } } return result as T @@ -109,10 +135,13 @@ public open class SwiftSQLiteConnectionPool @OptIn(ExperimentalForeignApi::class) override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { var result: T? = null - adapter.leaseWrite { - val lease = RawConnectionLease(it) - runBlocking { - result = callback(lease) + adapter.leaseWrite { lease -> + runWrapped { + val lease = RawConnectionLease(lease) + + runBlocking { + result = callback(lease) + } } } return result as T @@ -120,9 +149,16 @@ public open class SwiftSQLiteConnectionPool @OptIn(ExperimentalForeignApi::class) override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { - adapter.leaseAll { writerPtr, readerPtrs -> - runBlocking { - action(RawConnectionLease(writerPtr), readerPtrs.map { RawConnectionLease(it) }) + adapter.leaseAll { writerLease, readerLeases -> + runWrapped { + runBlocking { + action( + RawConnectionLease(writerLease), + readerLeases.map { + RawConnectionLease(it) + }, + ) + } } } } From 937d4520efb97348d8fe409f3b4b843ad05b7842 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 25 Sep 2025 16:13:10 +0200 Subject: [PATCH 24/28] Use SQLite Session API for Swift updates. --- PowerSyncKotlin/build.gradle.kts | 7 + .../powersync/SwiftSQLiteConnectionPool.kt | 184 ------------------ .../com/powersync/pool/RawConnectionLease.kt | 37 ++++ .../com/powersync/pool/SwiftPoolAdapter.kt | 71 +++++++ .../pool/SwiftSQLiteConnectionPool.kt | 111 +++++++++++ .../kotlin/com/powersync/pool/WithSession.kt | 159 +++++++++++++++ core/src/nativeMain/interop/sqlite3.def | 2 +- core/src/nativeMain/interop/sqlite3.h | 71 ++++++- .../com/powersync/compile/ClangCompile.kt | 5 + 9 files changed, 460 insertions(+), 187 deletions(-) delete mode 100644 PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt create mode 100644 PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/RawConnectionLease.kt create mode 100644 PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt create mode 100644 PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt create mode 100644 PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt diff --git a/PowerSyncKotlin/build.gradle.kts b/PowerSyncKotlin/build.gradle.kts index 505bf65f..3fa8d56d 100644 --- a/PowerSyncKotlin/build.gradle.kts +++ b/PowerSyncKotlin/build.gradle.kts @@ -48,6 +48,13 @@ kotlin { api(project(":core")) implementation(libs.ktor.client.logging) } + + all { + languageSettings { + optIn("kotlinx.cinterop.ExperimentalForeignApi") + optIn("com.powersync.ExperimentalPowerSyncAPI") + } + } } } diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt deleted file mode 100644 index 3af4b08f..00000000 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SwiftSQLiteConnectionPool.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.powersync - -import androidx.sqlite.SQLiteStatement -import cnames.structs.sqlite3 -import co.touchlab.kermit.Logger -import com.powersync.db.driver.SQLiteConnectionLease -import com.powersync.db.driver.SQLiteConnectionPool -import com.powersync.db.runWrapped -import com.powersync.db.schema.Schema -import com.powersync.sqlite.Database -import io.ktor.utils.io.CancellationException -import kotlinx.cinterop.CPointer -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.runBlocking - -@OptIn(ExperimentalPowerSyncAPI::class) -internal class RawConnectionLease - @OptIn(ExperimentalForeignApi::class) - constructor( - private val lease: SwiftLeaseAdapter, - ) : SQLiteConnectionLease { - private var isCompleted = false - - @OptIn(ExperimentalForeignApi::class) - private var db = Database(lease.pointer) - - private fun checkNotCompleted() { - check(!isCompleted) { "Connection lease already closed" } - } - - override suspend fun isInTransaction(): Boolean = isInTransactionSync() - - override fun isInTransactionSync(): Boolean { - checkNotCompleted() - return db.inTransaction() - } - - override suspend fun usePrepared( - sql: String, - block: (SQLiteStatement) -> R, - ): R = usePreparedSync(sql, block) - - override fun usePreparedSync( - sql: String, - block: (SQLiteStatement) -> R, - ): R { - checkNotCompleted() - return db.prepare(sql).use(block) - } - } - -public fun interface LeaseCallback { - @Throws(PowerSyncException::class, CancellationException::class) - public fun execute(lease: SwiftLeaseAdapter) -} - -public fun interface AllLeaseCallback { - @Throws(PowerSyncException::class, CancellationException::class) - public fun execute( - writeLease: SwiftLeaseAdapter, - readLeases: List, - ) -} - -public interface SwiftLeaseAdapter { - @OptIn(ExperimentalForeignApi::class) - public val pointer: CPointer -} - -/** - * We only allow synchronous callbacks on the Swift side for leased READ/WRITE connections. - * We also get a SQLite connection pointer (sqlite3*) from Swift side. which is used in a [Database] - */ - -public interface SwiftPoolAdapter { - @OptIn(ExperimentalForeignApi::class) - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun leaseRead(callback: LeaseCallback) - - @OptIn(ExperimentalForeignApi::class) - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun leaseWrite(callback: LeaseCallback) - - @OptIn(ExperimentalForeignApi::class) - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun leaseAll(callback: AllLeaseCallback) - - public fun linkUpdates(callback: suspend (Set) -> Unit) - - public suspend fun closePool() -} - -@OptIn(ExperimentalPowerSyncAPI::class) -public open class SwiftSQLiteConnectionPool - @OptIn(ExperimentalForeignApi::class) - constructor( - private val adapter: SwiftPoolAdapter, - ) : SQLiteConnectionPool { - private val _updates = MutableSharedFlow>(replay = 0) - override val updates: SharedFlow> get() = _updates - - init { - adapter.linkUpdates { tables -> - _updates.emit(tables) - } - } - - @OptIn(ExperimentalForeignApi::class) - override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { - var result: T? = null - adapter.leaseRead { - runWrapped { - /** - * For GRDB, this should be running inside the callback - * ```swift - * db.write { - * // should be here - * } - * ``` - */ - val lease = - RawConnectionLease(it) - runBlocking { - result = callback(lease) - } - } - } - return result as T - } - - @OptIn(ExperimentalForeignApi::class) - override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { - var result: T? = null - adapter.leaseWrite { lease -> - runWrapped { - val lease = RawConnectionLease(lease) - - runBlocking { - result = callback(lease) - } - } - } - return result as T - } - - @OptIn(ExperimentalForeignApi::class) - override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { - adapter.leaseAll { writerLease, readerLeases -> - runWrapped { - runBlocking { - action( - RawConnectionLease(writerLease), - readerLeases.map { - RawConnectionLease(it) - }, - ) - } - } - } - } - - override suspend fun close() { - adapter.closePool() - } - } - -@OptIn(ExperimentalPowerSyncAPI::class, DelicateCoroutinesApi::class) -public fun openPowerSyncWithPool( - pool: SQLiteConnectionPool, - identifier: String, - schema: Schema, - logger: Logger, -): PowerSyncDatabase = - PowerSyncDatabase.opened( - pool = pool, - scope = GlobalScope, - schema = schema, - identifier = identifier, - logger = logger, - ) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/RawConnectionLease.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/RawConnectionLease.kt new file mode 100644 index 00000000..12c4024d --- /dev/null +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/RawConnectionLease.kt @@ -0,0 +1,37 @@ +package com.powersync.pool + +import androidx.sqlite.SQLiteStatement +import com.powersync.db.driver.SQLiteConnectionLease +import com.powersync.sqlite.Database + +internal class RawConnectionLease( + lease: SwiftLeaseAdapter, +) : SQLiteConnectionLease { + private var isCompleted = false + + private var db = Database(lease.pointer) + + private fun checkNotCompleted() { + check(!isCompleted) { "Connection lease already closed" } + } + + override suspend fun isInTransaction(): Boolean = isInTransactionSync() + + override fun isInTransactionSync(): Boolean { + checkNotCompleted() + return db.inTransaction() + } + + override suspend fun usePrepared( + sql: String, + block: (SQLiteStatement) -> R, + ): R = usePreparedSync(sql, block) + + override fun usePreparedSync( + sql: String, + block: (SQLiteStatement) -> R, + ): R { + checkNotCompleted() + return db.prepare(sql).use(block) + } +} diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt new file mode 100644 index 00000000..4ef6d73a --- /dev/null +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt @@ -0,0 +1,71 @@ +package com.powersync.pool + +import cnames.structs.sqlite3 +import com.powersync.PowerSyncException +import com.powersync.sqlite.Database +import io.ktor.utils.io.CancellationException +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi + +/** + * A small functional interface to provide a callback with a leased connection. + * We use this structure in order to annotate the callback with exceptions that can be thrown. + */ +public fun interface LeaseCallback { + @Throws(PowerSyncException::class, CancellationException::class) + public fun execute(lease: SwiftLeaseAdapter) +} + +/** + * A small functional interface to provide a callback leases to all connections. + * We use this structure in order to annotate the callback with exceptions that can be thrown. + */ +public fun interface AllLeaseCallback { + @Throws(PowerSyncException::class, CancellationException::class) + public fun execute( + writeLease: SwiftLeaseAdapter, + readLeases: List, + ) +} + +/** + * The Swift lease will provide a SQLite connection pointer (sqlite3*) which is used in a [Database] + */ +public interface SwiftLeaseAdapter { + @OptIn(ExperimentalForeignApi::class) + public val pointer: CPointer +} + +/** + * We only allow synchronous callbacks on the Swift side for leased READ/WRITE connections. + * This adapter here uses synchronous callbacks. + * We also get a SQLite connection pointer (sqlite3*) from Swift side. which is used in a [Database] + * The adapter structure here is focused around easily integrating with a Swift Pool over SKIEE. + */ +public interface SwiftPoolAdapter { + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun leaseRead(callback: LeaseCallback) + + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun leaseWrite(callback: LeaseCallback) + + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun leaseAll(callback: AllLeaseCallback) + + /** + * Passes PowerSync operations to external logic. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun processPowerSyncUpdates(updates: Set) + + /** + * Links updates from external mutations to PowerSync. + */ + public fun linkExternalUpdates(callback: suspend (Set) -> Unit) + + /** + * Dispose any associated resources with the Pool and PowerSync. + * We don't manage the lifecycle of the pool. + */ + public suspend fun dispose() +} diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt new file mode 100644 index 00000000..8b6afe74 --- /dev/null +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt @@ -0,0 +1,111 @@ +package com.powersync.pool + +import co.touchlab.kermit.Logger +import com.powersync.ExperimentalPowerSyncAPI +import com.powersync.PowerSyncDatabase +import com.powersync.db.driver.SQLiteConnectionLease +import com.powersync.db.driver.SQLiteConnectionPool +import com.powersync.db.runWrapped +import com.powersync.db.schema.Schema +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.runBlocking + +/** + * Accepts a [SwiftPoolAdapter] to implement a [SQLiteConnectionPool] which + * is usable by PowerSync. + */ +public open class SwiftSQLiteConnectionPool( + private val adapter: SwiftPoolAdapter, +) : SQLiteConnectionPool { + private val _updates = MutableSharedFlow>(replay = 0) + override val updates: SharedFlow> get() = _updates + + init { + adapter.linkExternalUpdates { tables -> + _updates.emit(tables) + } + } + + @OptIn(ExperimentalForeignApi::class) + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { + var result: T? = null + adapter.leaseRead { + runWrapped { + /** + * For GRDB, this should be running inside the callback + * ```swift + * db.write { + * // should be here + * } + * ``` + */ + val lease = + RawConnectionLease(it) + runBlocking { + result = callback(lease) + } + } + } + return result as T + } + + @OptIn(ExperimentalForeignApi::class) + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { + var result: T? = null + var updates: Set = emptySet() + adapter.leaseWrite { lease -> + runWrapped { + val connectionLease = RawConnectionLease(lease) + updates = + withSession(lease.pointer) { + runBlocking { + result = callback(connectionLease) + } + } + } + } + // Inform the external adapter about the changes + adapter.processPowerSyncUpdates(updates) + // The adapter can pass these updates back to the shared flow + return result as T + } + + @OptIn(ExperimentalForeignApi::class) + override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { + adapter.leaseAll { writerLease, readerLeases -> + runWrapped { + runBlocking { + action( + RawConnectionLease(writerLease), + readerLeases.map { + RawConnectionLease(it) + }, + ) + } + } + } + } + + override suspend fun close() { + adapter.dispose() + } +} + +@OptIn(ExperimentalPowerSyncAPI::class, DelicateCoroutinesApi::class) +public fun openPowerSyncWithPool( + pool: SQLiteConnectionPool, + identifier: String, + schema: Schema, + logger: Logger, +): PowerSyncDatabase = + PowerSyncDatabase.Companion.opened( + pool = pool, + scope = GlobalScope, + schema = schema, + identifier = identifier, + logger = logger, + ) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt new file mode 100644 index 00000000..0672f254 --- /dev/null +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt @@ -0,0 +1,159 @@ +package com.powersync.pool + +import cnames.structs.sqlite3 +import cnames.structs.sqlite3_changeset_iter +import cnames.structs.sqlite3_session +import com.powersync.PowerSyncException +import com.powersync.db.runWrapped +import com.powersync.internal.sqlite3.sqlite3_free +import com.powersync.internal.sqlite3.sqlite3changeset_finalize +import com.powersync.internal.sqlite3.sqlite3changeset_next +import com.powersync.internal.sqlite3.sqlite3changeset_op +import com.powersync.internal.sqlite3.sqlite3changeset_start +import com.powersync.internal.sqlite3.sqlite3session_attach +import com.powersync.internal.sqlite3.sqlite3session_changeset +import com.powersync.internal.sqlite3.sqlite3session_create +import com.powersync.internal.sqlite3.sqlite3session_delete +import kotlinx.cinterop.* +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.COpaquePointerVar +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.CPointerVar +import kotlinx.cinterop.IntVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.toKString + +/** + * We typically have a few options for table update hooks: + * 1.) Registering a hook with SQLite + * 2.) Using our Rust core to register update hooks + * 3.) Receiving updates from an external API + * + * In some cases, particularly in the case of GRDB, none of these options are viable. + * GRDB dynamically registers (and unregisters) its own update hooks and its update hook logic + * does not report changes for operations made outside of its own APIs. + * + * 1.) We can't register our own hooks since GRDB might override it or our hook could conflict with GRDB's + * 2.) We can't register hooks due to above + * 3.) The GRDB APIs only report changes if made with their SQLite execution APIs. It's not trivial to implement [com.powersync.db.driver.SQLiteConnectionLease] with their APIs. + * + * This function provides an alternative method of obtaining table changes by using SQLite sessions. + * https://www.sqlite.org/sessionintro.html + * + * We start a session, execute a block of code, and then extract the changeset from the session. + * We then parse the changeset to extract the table names that were modified. + * This approach is more heavyweight than using update hooks, but it works in scenarios where + * update hooks are not currently feasible. + */ +@Throws(PowerSyncException::class) +public fun withSession( + db: CPointer, + block: () -> Unit, +): Set = + runWrapped { + memScoped { + val sessionPtr = alloc>() + + val rc = + sqlite3session_create( + db, + "main", + sessionPtr.ptr, + ).checkResult("Could not create SQLite session") + + val session = + sessionPtr.value ?: throw PowerSyncException( + "Could not create SQLite session", + cause = Error(), + ) + + try { + // Attach all tables to track changes + sqlite3session_attach( + session, + null, + ).checkResult("Could not attach all tables to session") // null means all tables + + // Execute the block where changes happen + block() + + // Get the changeset + val changesetSizePtr = alloc() + val changesetPtr = alloc() + + val changesetRc = + sqlite3session_changeset( + session, + changesetSizePtr.ptr, + changesetPtr.ptr, + ).checkResult("Could not get changeset from session") + + val changesetSize = changesetSizePtr.value + val changeset = changesetPtr.value + + if (changesetSize == 0 || changeset == null) { + return@memScoped emptySet() + } + + // Parse the changeset to extract table names + val changedTables = mutableSetOf() + val iterPtr = alloc>() + + val startRc = + sqlite3changeset_start( + iterPtr.ptr, + changesetSize, + changeset, + ).checkResult("Could not start changeset iterator") + + val iter = iterPtr.value + + if (iter == null) { + return@memScoped emptySet() + } + + try { + // Iterate through all changes + while (sqlite3changeset_next(iter) == 100) { + val tableNamePtr = alloc>() + val nColPtr = alloc() + val opPtr = alloc() + val indirectPtr = alloc() + + val opRc = + sqlite3changeset_op( + iter, + tableNamePtr.ptr, + nColPtr.ptr, + opPtr.ptr, + indirectPtr.ptr, + ) + + if (opRc == 0) { + val tableNameCPtr = tableNamePtr.value + if (tableNameCPtr != null) { + val tableName = tableNameCPtr.toKString() + changedTables.add(tableName) + } + } + } + } finally { + sqlite3changeset_finalize(iter) + // Free the changeset memory + sqlite3_free(changeset) + } + + return@memScoped changedTables.toSet() + } finally { + // Clean up the session + sqlite3session_delete(session) + } + } + } + +private fun Int.checkResult(message: String) { + if (this != 0) { + throw PowerSyncException("SQLite error code: $this", cause = Error(message)) + } +} diff --git a/core/src/nativeMain/interop/sqlite3.def b/core/src/nativeMain/interop/sqlite3.def index e90eb403..bb984917 100644 --- a/core/src/nativeMain/interop/sqlite3.def +++ b/core/src/nativeMain/interop/sqlite3.def @@ -1,3 +1,3 @@ headers = sqlite3.h -noStringConversion = sqlite3_prepare_v3 +noStringConversion = sqlite3_prepare_v3,sqlite3session_create diff --git a/core/src/nativeMain/interop/sqlite3.h b/core/src/nativeMain/interop/sqlite3.h index 52447d02..78aef1f8 100644 --- a/core/src/nativeMain/interop/sqlite3.h +++ b/core/src/nativeMain/interop/sqlite3.h @@ -3,36 +3,49 @@ typedef struct sqlite3 sqlite3; typedef struct sqlite3_stmt sqlite3_stmt; +typedef struct sqlite3_session sqlite3_session; +typedef struct sqlite3_changeset_iter sqlite3_changeset_iter; int sqlite3_initialize(); int sqlite3_open_v2(char *filename, sqlite3 **ppDb, int flags, char *zVfs); + int sqlite3_close_v2(sqlite3 *db); // Error handling int sqlite3_extended_result_codes(sqlite3 *db, int onoff); + int sqlite3_extended_errcode(sqlite3 *db); + char *sqlite3_errmsg(sqlite3 *db); + char *sqlite3_errstr(int code); + int sqlite3_error_offset(sqlite3 *db); + void sqlite3_free(void *ptr); // Versions char *sqlite3_libversion(); + char *sqlite3_sourceid(); + int sqlite3_libversion_number(); // Database int sqlite3_get_autocommit(sqlite3 *db); + int sqlite3_db_config(sqlite3 *db, int op, ...); + int sqlite3_load_extension( sqlite3 *db, /* Load the extension into this database connection */ const char *zFile, /* Name of the shared library containing extension */ const char *zProc, /* Entry point. Derived from zFile if 0 */ char **pzErrMsg /* Put error message here if not 0 */ ); -int sqlite3_extended_result_codes(sqlite3*, int onoff); + +int sqlite3_extended_result_codes(sqlite3 *, int onoff); // Statements int sqlite3_prepare16_v3( @@ -43,27 +56,81 @@ int sqlite3_prepare16_v3( sqlite3_stmt **ppStmt, /* OUT: Statement handle */ const void **pzTail /* OUT: Pointer to unused portion of zSql */ ); + int sqlite3_finalize(sqlite3_stmt *pStmt); + int sqlite3_step(sqlite3_stmt *pStmt); + int sqlite3_reset(sqlite3_stmt *pStmt); -int sqlite3_clear_bindings(sqlite3_stmt*); + +int sqlite3_clear_bindings(sqlite3_stmt *); int sqlite3_column_count(sqlite3_stmt *pStmt); + int sqlite3_bind_parameter_count(sqlite3_stmt *pStmt); + char *sqlite3_column_name(sqlite3_stmt *pStmt, int N); int sqlite3_bind_blob64(sqlite3_stmt *pStmt, int index, void *data, uint64_t length, void *destructor); + int sqlite3_bind_double(sqlite3_stmt *pStmt, int index, double data); + int sqlite3_bind_int64(sqlite3_stmt *pStmt, int index, int64_t data); + int sqlite3_bind_null(sqlite3_stmt *pStmt, int index); + int sqlite3_bind_text16(sqlite3_stmt *pStmt, int index, char *data, int length, void *destructor); void *sqlite3_column_blob(sqlite3_stmt *pStmt, int iCol); + double sqlite3_column_double(sqlite3_stmt *pStmt, int iCol); + int64_t sqlite3_column_int64(sqlite3_stmt *pStmt, int iCol); + void *sqlite3_column_text16(sqlite3_stmt *pStmt, int iCol); + int sqlite3_column_bytes(sqlite3_stmt *pStmt, int iCol); + int sqlite3_column_bytes16(sqlite3_stmt *pStmt, int iCol); + int sqlite3_column_type(sqlite3_stmt *pStmt, int iCol); + + +int sqlite3session_create( + sqlite3 *db, /* Database handle */ + const char *zDb, /* Name of db (e.g. "main") */ + sqlite3_session **ppSession /* OUT: New session object */ +); + +int sqlite3session_attach( + sqlite3_session *pSession, /* Session object */ + const char *zTab /* Table name or NULL for all */ +); + +int sqlite3session_changeset( + sqlite3_session *pSession, /* Session object */ + int *pnChangeset, /* OUT: Size of buffer at *ppChangeset */ + void **ppChangeset /* OUT: Buffer containing changeset */ +); + +int sqlite3changeset_start( + sqlite3_changeset_iter **pp, /* OUT: New changeset iterator handle */ + int nChangeset, /* Size of changeset blob in bytes */ + void *pChangeset /* Pointer to blob containing changeset */ +); + +int sqlite3changeset_op( + sqlite3_changeset_iter *pIter, /* Iterator object */ + const char **pzTab, /* OUT: Pointer to table name */ + int *pnCol, /* OUT: Number of columns in table */ + int *pOp, /* OUT: SQLITE_INSERT, DELETE or UPDATE */ + int *pbIndirect /* OUT: True for an 'indirect' change */ +); + +int sqlite3changeset_next(sqlite3_changeset_iter *pIter); + +int sqlite3changeset_finalize(sqlite3_changeset_iter *pIter); + +void sqlite3session_delete(sqlite3_session *pSession); \ No newline at end of file diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt index 1b1b3d45..914e7aef 100644 --- a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt +++ b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt @@ -99,7 +99,12 @@ abstract class ClangCompile : DefaultTask() { "-DSQLITE_ENABLE_DBSTAT_VTAB", "-DSQLITE_ENABLE_FTS5", "-DSQLITE_ENABLE_RTREE", + // Used by GRDB "-DSQLITE_ENABLE_SNAPSHOT", + // Used for GRDB update hook like functionality + "-DSQLITE_ENABLE_SESSION", + "-DSQLITE_ENABLE_PREUPDATE_HOOK", + // "-O3", "-o", output.asFile.toPath().name, From 59408b0c3c68b654865c86294dd748feb8a2fd6f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 25 Sep 2025 16:39:20 +0200 Subject: [PATCH 25/28] Code cleanup. Fix lint error. --- .../kotlin/com/powersync/pool/SwiftPoolAdapter.kt | 1 - .../com/powersync/pool/SwiftSQLiteConnectionPool.kt | 8 +++----- .../appleMain/kotlin/com/powersync/pool/WithSession.kt | 3 ++- .../kotlin/com/powersync/db/ActiveInstanceStore.kt | 2 +- .../nativeMain/kotlin/com/powersync/sqlite/Database.kt | 5 +++-- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt index 4ef6d73a..75e12521 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt @@ -32,7 +32,6 @@ public fun interface AllLeaseCallback { * The Swift lease will provide a SQLite connection pointer (sqlite3*) which is used in a [Database] */ public interface SwiftLeaseAdapter { - @OptIn(ExperimentalForeignApi::class) public val pointer: CPointer } diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt index 8b6afe74..86a04595 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.runBlocking * Accepts a [SwiftPoolAdapter] to implement a [SQLiteConnectionPool] which * is usable by PowerSync. */ -public open class SwiftSQLiteConnectionPool( +public class SwiftSQLiteConnectionPool( private val adapter: SwiftPoolAdapter, ) : SQLiteConnectionPool { private val _updates = MutableSharedFlow>(replay = 0) @@ -30,7 +30,6 @@ public open class SwiftSQLiteConnectionPool( } } - @OptIn(ExperimentalForeignApi::class) override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { var result: T? = null adapter.leaseRead { @@ -50,10 +49,10 @@ public open class SwiftSQLiteConnectionPool( } } } + @Suppress("UNCHECKED_CAST") return result as T } - @OptIn(ExperimentalForeignApi::class) override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { var result: T? = null var updates: Set = emptySet() @@ -71,10 +70,10 @@ public open class SwiftSQLiteConnectionPool( // Inform the external adapter about the changes adapter.processPowerSyncUpdates(updates) // The adapter can pass these updates back to the shared flow + @Suppress("UNCHECKED_CAST") return result as T } - @OptIn(ExperimentalForeignApi::class) override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { adapter.leaseAll { writerLease, readerLeases -> runWrapped { @@ -95,7 +94,6 @@ public open class SwiftSQLiteConnectionPool( } } -@OptIn(ExperimentalPowerSyncAPI::class, DelicateCoroutinesApi::class) public fun openPowerSyncWithPool( pool: SQLiteConnectionPool, identifier: String, diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt index 0672f254..d4c4b505 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt @@ -14,7 +14,6 @@ import com.powersync.internal.sqlite3.sqlite3session_attach import com.powersync.internal.sqlite3.sqlite3session_changeset import com.powersync.internal.sqlite3.sqlite3session_create import com.powersync.internal.sqlite3.sqlite3session_delete -import kotlinx.cinterop.* import kotlinx.cinterop.ByteVar import kotlinx.cinterop.COpaquePointerVar import kotlinx.cinterop.CPointer @@ -22,7 +21,9 @@ import kotlinx.cinterop.CPointerVar import kotlinx.cinterop.IntVar import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr import kotlinx.cinterop.toKString +import kotlinx.cinterop.value /** * We typically have a few options for table update hooks: diff --git a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt index 69528a79..1ba3faed 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt @@ -82,7 +82,7 @@ internal class ActiveDatabaseGroup( } } -internal class ActiveDatabaseResource constructor( +internal class ActiveDatabaseResource( val group: ActiveDatabaseGroup, ) { val disposed = AtomicBoolean(false) diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt index 6931b7c4..4d24ca75 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt +++ b/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt @@ -52,13 +52,14 @@ public class Database( Statement(sql, ptr, stmtPtr.value!!) } - public fun loadExtension( + internal fun loadExtension( filename: String, entrypoint: String, ): Unit = memScoped { val errorMessagePointer = alloc>() - val resultCode = sqlite3_load_extension(ptr, filename, entrypoint, errorMessagePointer.ptr) + val resultCode = + sqlite3_load_extension(ptr, filename, entrypoint, errorMessagePointer.ptr) if (resultCode != 0) { val errorMessage = errorMessagePointer.value?.toKStringFromUtf8() From 587934c36eacb32dd6b5c7098fc733ee0e88200a Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 29 Sep 2025 14:48:39 +0200 Subject: [PATCH 26/28] cleanup APIs for sessions --- .../com/powersync/pool/SwiftPoolAdapter.kt | 6 --- .../pool/SwiftSQLiteConnectionPool.kt | 13 ++----- .../kotlin/com/powersync/pool/WithSession.kt | 39 ++++++++++--------- 3 files changed, 24 insertions(+), 34 deletions(-) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt index 75e12521..57158640 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt @@ -51,12 +51,6 @@ public interface SwiftPoolAdapter { @Throws(PowerSyncException::class, CancellationException::class) public suspend fun leaseAll(callback: AllLeaseCallback) - /** - * Passes PowerSync operations to external logic. - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun processPowerSyncUpdates(updates: Set) - /** * Links updates from external mutations to PowerSync. */ diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt index 86a04595..f8564064 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt @@ -55,21 +55,14 @@ public class SwiftSQLiteConnectionPool( override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { var result: T? = null - var updates: Set = emptySet() adapter.leaseWrite { lease -> runWrapped { val connectionLease = RawConnectionLease(lease) - updates = - withSession(lease.pointer) { - runBlocking { - result = callback(connectionLease) - } - } + runBlocking { + result = callback(connectionLease) + } } } - // Inform the external adapter about the changes - adapter.processPowerSyncUpdates(updates) - // The adapter can pass these updates back to the shared flow @Suppress("UNCHECKED_CAST") return result as T } diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt index d4c4b505..f40783e0 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt @@ -4,6 +4,7 @@ import cnames.structs.sqlite3 import cnames.structs.sqlite3_changeset_iter import cnames.structs.sqlite3_session import com.powersync.PowerSyncException +import com.powersync.PowerSyncResult import com.powersync.db.runWrapped import com.powersync.internal.sqlite3.sqlite3_free import com.powersync.internal.sqlite3.sqlite3changeset_finalize @@ -50,8 +51,9 @@ import kotlinx.cinterop.value @Throws(PowerSyncException::class) public fun withSession( db: CPointer, - block: () -> Unit, -): Set = + onComplete: (PowerSyncResult, Set) -> Unit, + block: () -> PowerSyncResult, +): Unit = runWrapped { memScoped { val sessionPtr = alloc>() @@ -77,41 +79,41 @@ public fun withSession( ).checkResult("Could not attach all tables to session") // null means all tables // Execute the block where changes happen - block() + val result = block() // Get the changeset val changesetSizePtr = alloc() val changesetPtr = alloc() - val changesetRc = - sqlite3session_changeset( - session, - changesetSizePtr.ptr, - changesetPtr.ptr, - ).checkResult("Could not get changeset from session") + sqlite3session_changeset( + session, + changesetSizePtr.ptr, + changesetPtr.ptr, + ).checkResult("Could not get changeset from session") val changesetSize = changesetSizePtr.value val changeset = changesetPtr.value if (changesetSize == 0 || changeset == null) { - return@memScoped emptySet() + onComplete(result, emptySet()) + return@memScoped } // Parse the changeset to extract table names val changedTables = mutableSetOf() val iterPtr = alloc>() - val startRc = - sqlite3changeset_start( - iterPtr.ptr, - changesetSize, - changeset, - ).checkResult("Could not start changeset iterator") + sqlite3changeset_start( + iterPtr.ptr, + changesetSize, + changeset, + ).checkResult("Could not start changeset iterator") val iter = iterPtr.value if (iter == null) { - return@memScoped emptySet() + onComplete(result, emptySet()) + return@memScoped } try { @@ -145,7 +147,8 @@ public fun withSession( sqlite3_free(changeset) } - return@memScoped changedTables.toSet() + onComplete(result, changedTables.toSet()) + return@memScoped } finally { // Clean up the session sqlite3session_delete(session) From 9f855e4aa51ed3da6b10525274eed3f999a482d2 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 3 Oct 2025 15:09:27 +0200 Subject: [PATCH 27/28] move Swift pool logic --- .../kotlin/com/powersync/pool/RawConnectionLease.kt | 0 .../appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt | 0 .../kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt | 6 ++++++ .../src/appleMain/kotlin/com/powersync/pool/WithSession.kt | 0 4 files changed, 6 insertions(+) rename {PowerSyncKotlin => internal/PowerSyncKotlin}/src/appleMain/kotlin/com/powersync/pool/RawConnectionLease.kt (100%) rename {PowerSyncKotlin => internal/PowerSyncKotlin}/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt (100%) rename {PowerSyncKotlin => internal/PowerSyncKotlin}/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt (90%) rename {PowerSyncKotlin => internal/PowerSyncKotlin}/src/appleMain/kotlin/com/powersync/pool/WithSession.kt (100%) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/RawConnectionLease.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/RawConnectionLease.kt similarity index 100% rename from PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/RawConnectionLease.kt rename to internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/RawConnectionLease.kt diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt similarity index 100% rename from PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt rename to internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt similarity index 90% rename from PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt rename to internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt index f8564064..0aeb51d0 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt +++ b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt @@ -32,6 +32,12 @@ public class SwiftSQLiteConnectionPool( override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { var result: T? = null + /** + * The leaseRead and leaseWrite callbacks don't return values + * since the SKIEE generated version maps to returning Any? Which Swift + * will warn when overriding the method since it's throwable and nil typically + * represents an error in Objective C. + */ adapter.leaseRead { runWrapped { /** diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt similarity index 100% rename from PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt rename to internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt From 11add5c5f44264d4469808887bff7184aea55b5b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 3 Oct 2025 16:01:14 +0200 Subject: [PATCH 28/28] Add changelog entry --- CHANGELOG.md | 4 +++- .../kotlin/com/powersync/pool/SwiftPoolAdapter.kt | 14 +++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3123235..064b38bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ - Add `PowerSyncDatabase.inMemory` to create an in-memory SQLite database with PowerSync. This may be useful for testing. -- The Supabase connector can now be subclassed to customize how rows are uploaded and how errors are handled. +- The Supabase connector can now be subclassed to customize how rows are uploaded and how errors are + handled. - Experimental support for sync streams. +- [Swift] Added helpers for creating Swift SQLite connection pools. ## 1.6.1 diff --git a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt index 57158640..877bcf40 100644 --- a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt +++ b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftPoolAdapter.kt @@ -7,6 +7,13 @@ import io.ktor.utils.io.CancellationException import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi +/** + * The Swift lease will provide a SQLite connection pointer (sqlite3*) which is used in a [Database] + */ +public interface SwiftLeaseAdapter { + public val pointer: CPointer +} + /** * A small functional interface to provide a callback with a leased connection. * We use this structure in order to annotate the callback with exceptions that can be thrown. @@ -28,13 +35,6 @@ public fun interface AllLeaseCallback { ) } -/** - * The Swift lease will provide a SQLite connection pointer (sqlite3*) which is used in a [Database] - */ -public interface SwiftLeaseAdapter { - public val pointer: CPointer -} - /** * We only allow synchronous callbacks on the Swift side for leased READ/WRITE connections. * This adapter here uses synchronous callbacks.