From 136e809943f3f0c8393577e42a09fbf8181d5716 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 22 Jul 2025 23:17:15 +0200 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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. From be8617e4aaf70e569f1a23601de68fc5b7b51ea0 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 17 Oct 2025 10:38:56 +0200 Subject: [PATCH 29/41] Start moving code into common --- common/build.gradle.kts | 277 +++++++++++++++++ .../src/androidMain/kotlin/BuildConfig.kt | 0 .../DatabaseDriverFactory.android.kt | 5 + .../com/powersync/sync/UserAgent.android.kt | 0 .../src/appleMain/kotlin/BuildConfig.kt | 0 .../kotlin/com/powersync/PathUtils.kt | 6 +- .../DatabaseDriverFactory.appleNonWatchOs.kt | 5 + .../kotlin/com/powersync/AttachmentsTest.kt | 0 .../kotlin/com/powersync/CrudTest.kt | 0 .../kotlin/com/powersync/DatabaseTest.kt | 0 .../kotlin/com/powersync/db/InMemoryTest.kt | 0 .../com/powersync/sync/AbstractSyncTest.kt | 0 .../com/powersync/sync/SyncIntegrationTest.kt | 2 - .../com/powersync/sync/SyncProgressTest.kt | 0 .../com/powersync/sync/SyncStreamTest.kt | 0 .../testutils/MockedRemoteStorage.kt | 0 .../com/powersync/testutils/TestUtils.kt | 5 +- .../kotlin/com/powersync/testutils/UserRow.kt | 0 .../db/ActiveInstanceStore.commonJava.kt | 0 .../src/commonMain/kotlin/BuildConfig.kt | 3 +- .../com/powersync/DatabaseDriverFactory.kt | 68 ++++ .../com/powersync/ExperimentalPowerSyncAPI.kt | 0 .../kotlin/com/powersync/PowerSyncDatabase.kt | 266 ++++++++++++++++ .../com/powersync/PowerSyncDatabaseFactory.kt | 4 +- .../com/powersync/PowerSyncException.kt | 0 .../com/powersync/attachments/Attachment.kt | 0 .../powersync/attachments/AttachmentQueue.kt | 0 .../attachments/AttachmentService.kt | 0 .../powersync/attachments/AttachmentTable.kt | 0 .../com/powersync/attachments/LocalStorage.kt | 0 .../com/powersync/attachments/README.md | 0 .../powersync/attachments/RemoteStorage.kt | 0 .../powersync/attachments/SyncErrorHandler.kt | 0 .../implementation/AttachmentContextImpl.kt | 0 .../implementation/AttachmentServiceImpl.kt | 0 .../storage/IOLocalStorageAdapter.kt | 0 .../attachments/sync/SyncingService.kt | 0 .../com/powersync/bucket/BucketChecksum.kt | 0 .../com/powersync/bucket/BucketRequest.kt | 0 .../com/powersync/bucket/BucketState.kt | 0 .../com/powersync/bucket/BucketStorage.kt | 0 .../com/powersync/bucket/BucketStorageImpl.kt | 0 .../kotlin/com/powersync/bucket/Checkpoint.kt | 0 .../com/powersync/bucket/ChecksumCache.kt | 0 .../bucket/LocalOperationCounters.kt | 0 .../kotlin/com/powersync/bucket/OpType.kt | 0 .../kotlin/com/powersync/bucket/OplogEntry.kt | 0 .../kotlin/com/powersync/bucket/SqliteOp.kt | 0 .../com/powersync/bucket/StreamPriority.kt | 0 .../powersync/bucket/WriteCheckpointResult.kt | 0 .../connectors/PowerSyncBackendConnector.kt | 0 .../connectors/PowerSyncCredentials.kt | 0 .../com/powersync/db/ActiveInstanceStore.kt | 30 +- .../kotlin/com/powersync/db/Functions.kt | 0 .../com/powersync/db/PowerSyncDatabaseImpl.kt | 0 .../kotlin/com/powersync/db/Queries.kt | 0 .../kotlin/com/powersync/db/SqlCursor.kt | 0 .../kotlin/com/powersync/db/StreamImpl.kt | 0 .../kotlin/com/powersync/db/crud/CrudBatch.kt | 0 .../kotlin/com/powersync/db/crud/CrudEntry.kt | 0 .../kotlin/com/powersync/db/crud/CrudRow.kt | 0 .../com/powersync/db/crud/CrudTransaction.kt | 0 .../com/powersync/db/crud/SerializedRow.kt | 0 .../com/powersync/db/crud/UpdateType.kt | 0 .../com/powersync/db/crud/UploadQueueStats.kt | 0 .../db/driver/InternalConnectionPool.kt | 8 +- .../com/powersync/db/driver/LazyPool.kt | 0 .../powersync/db/driver/RawConnectionLease.kt | 0 .../com/powersync/db/driver/ReadPool.kt | 0 .../db/driver/SQLiteConnectionPool.kt | 0 .../db/driver/SingleConnectionPool.kt | 5 +- .../db/internal/ConnectionContext.kt | 0 .../powersync/db/internal/InternalDatabase.kt | 0 .../db/internal/InternalDatabaseImpl.kt | 0 .../powersync/db/internal/InternalTable.kt | 0 .../db/internal/PowerSyncTransaction.kt | 0 .../powersync/db/internal/PowerSyncVersion.kt | 0 .../com/powersync/db/schema/BaseTable.kt | 0 .../kotlin/com/powersync/db/schema/Column.kt | 0 .../com/powersync/db/schema/ColumnType.kt | 0 .../kotlin/com/powersync/db/schema/Index.kt | 0 .../com/powersync/db/schema/IndexedColumn.kt | 0 .../com/powersync/db/schema/RawTable.kt | 0 .../kotlin/com/powersync/db/schema/Schema.kt | 0 .../kotlin/com/powersync/db/schema/Table.kt | 0 .../com/powersync/db/schema/validation.kt | 0 .../kotlin/com/powersync/sync/Instruction.kt | 0 .../sync/LegacySyncImplementation.kt | 0 .../kotlin/com/powersync/sync/Progress.kt | 0 .../kotlin/com/powersync/sync/Stream.kt | 0 .../com/powersync/sync/StreamingSync.kt | 0 .../powersync/sync/StreamingSyncRequest.kt | 0 .../com/powersync/sync/SyncDataBatch.kt | 0 .../kotlin/com/powersync/sync/SyncLine.kt | 0 .../powersync/sync/SyncLocalDatabaseResult.kt | 0 .../kotlin/com/powersync/sync/SyncOptions.kt | 0 .../kotlin/com/powersync/sync/SyncStatus.kt | 0 .../kotlin/com/powersync/sync/UserAgent.kt | 0 .../com/powersync/utils/AtomicMutableSet.kt | 0 .../kotlin/com/powersync/utils/Json.kt | 0 .../kotlin/com/powersync/utils/Log.kt | 0 .../kotlin/com/powersync/utils/Strings.kt | 0 .../com/powersync/utils/ThrottleFlow.kt | 0 .../src/jvmMain/kotlin/BuildConfig.kt | 2 +- .../powersync/DatabaseDriverFactory.jvm.kt | 9 + .../kotlin/com/powersync/ExtractLib.kt | 0 .../com/powersync/sync/UserAgent.jvm.kt | 0 .../src/nativeMain/interop/sqlite3.def | 0 .../src/nativeMain/interop/sqlite3.h | 0 .../db/ActiveInstanceStore.native.kt | 0 .../kotlin/com/powersync/sqlite/Database.kt | 10 +- .../com/powersync/sqlite/SqliteException.kt | 0 .../kotlin/com/powersync/sqlite/Statement.kt | 0 .../com/powersync/sync/UserAgent.native.kt | 0 .../com.powersync.sqlite}/DatabaseTest.kt | 0 .../com.powersync.sqlite}/StatementTest.kt | 0 .../DatabaseDriverFactory.watchos.kt | 25 ++ common/src/watchosMain/powersync_static.h | 1 + core/build.gradle.kts | 128 +------- .../DatabaseDriverFactory.android.kt | 13 +- .../DatabaseDriverFactory.appleNonWatchOs.kt | 16 +- .../powersync/DatabaseDriverFactoryTest.kt | 4 +- .../com/powersync/DatabaseDriverFactory.kt | 61 +--- .../kotlin/com/powersync/PowerSyncDatabase.kt | 294 ++---------------- .../kotlin/com/powersync/db/FunctionTest.kt | 1 - .../powersync/DatabaseDriverFactory.jvm.kt | 20 +- .../DatabaseDriverFactory.watchos.kt | 35 +-- settings.gradle.kts | 1 + 128 files changed, 760 insertions(+), 544 deletions(-) create mode 100644 common/build.gradle.kts rename {core => common}/src/androidMain/kotlin/BuildConfig.kt (100%) create mode 100644 common/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt rename {core => common}/src/androidMain/kotlin/com/powersync/sync/UserAgent.android.kt (100%) rename {core => common}/src/appleMain/kotlin/BuildConfig.kt (100%) rename core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt => common/src/appleMain/kotlin/com/powersync/PathUtils.kt (92%) create mode 100644 common/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt (100%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt (100%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt (100%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt (100%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt (100%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt (99%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt (100%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncStreamTest.kt (100%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/testutils/MockedRemoteStorage.kt (100%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt (97%) rename {core => common}/src/commonIntegrationTest/kotlin/com/powersync/testutils/UserRow.kt (100%) rename {core => common}/src/commonJava/kotlin/com/powersync/db/ActiveInstanceStore.commonJava.kt (100%) rename {core => common}/src/commonMain/kotlin/BuildConfig.kt (50%) create mode 100644 common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt rename {core => common}/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt (100%) create mode 100644 common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt rename {core => common}/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt (96%) rename {core => common}/src/commonMain/kotlin/com/powersync/PowerSyncException.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/Attachment.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/AttachmentQueue.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/AttachmentService.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/AttachmentTable.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/LocalStorage.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/README.md (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/RemoteStorage.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/SyncErrorHandler.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentContextImpl.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentServiceImpl.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/storage/IOLocalStorageAdapter.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/attachments/sync/SyncingService.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/BucketState.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/LocalOperationCounters.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/OpType.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/OplogEntry.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/SqliteOp.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/StreamPriority.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/bucket/WriteCheckpointResult.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/connectors/PowerSyncCredentials.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt (77%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/Functions.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/Queries.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/StreamImpl.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/crud/CrudBatch.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/crud/CrudRow.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/crud/CrudTransaction.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/crud/UpdateType.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/crud/UploadQueueStats.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt (95%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/driver/LazyPool.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt (95%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabase.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/internal/InternalTable.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncVersion.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/schema/BaseTable.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/schema/Column.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/schema/ColumnType.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/schema/Index.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/schema/IndexedColumn.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/schema/RawTable.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/schema/Schema.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/schema/Table.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/db/schema/validation.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/Instruction.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/Progress.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/Stream.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/SyncLine.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/SyncLocalDatabaseResult.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/sync/UserAgent.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/utils/AtomicMutableSet.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/utils/Json.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/utils/Log.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/utils/Strings.kt (100%) rename {core => common}/src/commonMain/kotlin/com/powersync/utils/ThrottleFlow.kt (100%) rename {core => common}/src/jvmMain/kotlin/BuildConfig.kt (89%) create mode 100644 common/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt rename {core => common}/src/jvmMain/kotlin/com/powersync/ExtractLib.kt (100%) rename {core => common}/src/jvmMain/kotlin/com/powersync/sync/UserAgent.jvm.kt (100%) rename {core => common}/src/nativeMain/interop/sqlite3.def (100%) rename {core => common}/src/nativeMain/interop/sqlite3.h (100%) rename {core => common}/src/nativeMain/kotlin/com/powersync/db/ActiveInstanceStore.native.kt (100%) rename {core => common}/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt (96%) rename {core => common}/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt (100%) rename {core => common}/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt (100%) rename {core => common}/src/nativeMain/kotlin/com/powersync/sync/UserAgent.native.kt (100%) rename {core/src/appleTest/kotlin/com/powersync/sqlite => common/src/nativeTest/kotlin/com.powersync.sqlite}/DatabaseTest.kt (100%) rename {core/src/appleTest/kotlin/com/powersync/sqlite => common/src/nativeTest/kotlin/com.powersync.sqlite}/StatementTest.kt (100%) create mode 100644 common/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt create mode 100644 common/src/watchosMain/powersync_static.h diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 00000000..207b0518 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,277 @@ +import com.powersync.plugins.utils.powersyncTargets +import de.undercouch.gradle.tasks.download.Download +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.internal.os.OperatingSystem +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinTest +import org.jetbrains.kotlin.konan.target.Family +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.android.library) + alias(libs.plugins.mavenPublishPlugin) + alias(libs.plugins.downloadPlugin) + alias(libs.plugins.kotlinter) + id("com.powersync.plugins.sonatype") + id("com.powersync.plugins.sharedbuild") + alias(libs.plugins.mokkery) + alias(libs.plugins.kotlin.atomicfu) + id("dokka-convention") +} + +val binariesFolder = project.layout.buildDirectory.dir("binaries/desktop") +val downloadPowersyncDesktopBinaries by tasks.registering(Download::class) { + description = "Download PowerSync core extensions for JVM builds and releases" + + val coreVersion = + libs.versions.powersync.core + .get() + val linux_aarch64 = + "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/libpowersync_aarch64.so" + val linux_x64 = + "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/libpowersync_x64.so" + val macos_aarch64 = + "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/libpowersync_aarch64.dylib" + val macos_x64 = + "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/libpowersync_x64.dylib" + val windows_x64 = + "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/powersync_x64.dll" + + val includeAllPlatformsForJvmBuild = + project.findProperty("powersync.binaries.allPlatforms") == "true" + val os = OperatingSystem.current() + + // The jar we're releasing for JVM clients needs to include the core extension. For local tests, it's enough to only + // download the extension for the OS running the build. For releases, we want to include them all. + // We're not compiling native code for JVM builds here (we're doing that for Android only), so we just have to + // fetch prebuilt binaries from the powersync-sqlite-core repository. + if (includeAllPlatformsForJvmBuild) { + src(listOf(linux_aarch64, linux_x64, macos_aarch64, macos_x64, windows_x64)) + } else { + val (aarch64, x64) = + when { + os.isLinux -> linux_aarch64 to linux_x64 + os.isMacOsX -> macos_aarch64 to macos_x64 + os.isWindows -> null to windows_x64 + else -> error("Unknown operating system: $os") + } + val arch = System.getProperty("os.arch") + src( + when (arch) { + "aarch64" -> listOfNotNull(aarch64) + "amd64", "x86_64" -> listOfNotNull(x64) + else -> error("Unsupported architecture: $arch") + }, + ) + } + dest(binariesFolder.map { it.dir("powersync") }) + onlyIfModified(true) +} + +val generateVersionConstant by tasks.registering { + val target = project.layout.buildDirectory.dir("generated/constants") + val packageName = "com.powersync.build" + + outputs.dir(target) + val currentVersion = version.toString() + + doLast { + val dir = target.get().asFile + dir.mkdir() + val rootPath = dir.toPath() + + val source = + """ + package $packageName + + internal const val LIBRARY_VERSION: String = "$currentVersion" + + """.trimIndent() + + val packageRoot = packageName.split('.').fold(rootPath, Path::resolve) + packageRoot.createDirectories() + + packageRoot.resolve("BuildConstants.kt").writeText(source) + } +} + +kotlin { + powersyncTargets() + + targets.withType { + compilations.named("main") { + compileTaskProvider { + compilerOptions.freeCompilerArgs.add("-Xexport-kdoc") + } + + if (target.konanTarget.family == Family.WATCHOS) { + // We're linking the core extension statically, which means that we need a cinterop + // to call powersync_init_static + cinterops.create("powersync_static") { + packageName("com.powersync.static") + 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")) + } + } + } + + explicitApi() + + applyDefaultHierarchyTemplate() + sourceSets { + all { + languageSettings { + optIn("kotlinx.cinterop.ExperimentalForeignApi") + optIn("kotlin.time.ExperimentalTime") + optIn("kotlin.experimental.ExperimentalObjCRefinement") + } + } + + val commonIntegrationTest by creating { + dependsOn(commonTest.get()) + } + + val commonJava by creating { + dependsOn(commonMain.get()) + } + + commonMain.configure { + kotlin { + srcDir(generateVersionConstant) + } + + dependencies { + api(libs.androidx.sqlite.sqlite) + + implementation(libs.uuid) + implementation(libs.kotlin.stdlib) + implementation(libs.ktor.client.contentnegotiation) + implementation(libs.ktor.serialization.json) + implementation(libs.kotlinx.io) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + implementation(libs.stately.concurrency) + implementation(libs.configuration.annotations) + api(libs.ktor.client.core) + api(libs.kermit) + } + } + + androidMain { + dependsOn(commonJava) + dependencies { + api(libs.powersync.sqlite.core.android) + implementation(libs.ktor.client.okhttp) + implementation(libs.androidx.sqlite.bundled) + } + } + + jvmMain { + dependsOn(commonJava) + + 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. + 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) + implementation(libs.test.turbine) + implementation(libs.test.kotest.assertions) + implementation(libs.kermit.test) + implementation(libs.ktor.client.mock) + implementation(libs.test.turbine) + } + + // We're putting the native libraries into our JAR, so integration tests for the JVM can run as part of the unit + // tests. + jvmTest.get().dependsOn(commonIntegrationTest) + + // We have special setup in this build configuration to make these tests link the PowerSync extension, so they + // can run integration tests along with the executable for unit testing. + appleTest.orNull?.dependsOn(commonIntegrationTest) + } +} + +android { + compileOptions { + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + buildConfig = true + } + + buildTypes { + release { + buildConfigField("boolean", "DEBUG", "false") + } + debug { + buildConfigField("boolean", "DEBUG", "true") + } + } + + namespace = "com.powersync" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + defaultConfig { + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + consumerProguardFiles("proguard-rules.pro") + } + + ndkVersion = "27.1.12297006" +} + +tasks.named(kotlin.jvm().compilations["main"].processResourcesTaskName) { + from(downloadPowersyncDesktopBinaries) +} + +tasks.withType { + testLogging { + events("PASSED", "FAILED", "SKIPPED") + exceptionFormat = TestExceptionFormat.FULL + showCauses = true + showStandardStreams = true + showStackTraces = true + } +} + +dokka { + moduleName.set("PowerSync Common") +} diff --git a/core/src/androidMain/kotlin/BuildConfig.kt b/common/src/androidMain/kotlin/BuildConfig.kt similarity index 100% rename from core/src/androidMain/kotlin/BuildConfig.kt rename to common/src/androidMain/kotlin/BuildConfig.kt diff --git a/common/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/common/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt new file mode 100644 index 00000000..2c35a546 --- /dev/null +++ b/common/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -0,0 +1,5 @@ +package com.powersync + +@ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) +public actual fun resolvePowerSyncLoadableExtensionPath(): String? = "libpowersync.so" diff --git a/core/src/androidMain/kotlin/com/powersync/sync/UserAgent.android.kt b/common/src/androidMain/kotlin/com/powersync/sync/UserAgent.android.kt similarity index 100% rename from core/src/androidMain/kotlin/com/powersync/sync/UserAgent.android.kt rename to common/src/androidMain/kotlin/com/powersync/sync/UserAgent.android.kt diff --git a/core/src/appleMain/kotlin/BuildConfig.kt b/common/src/appleMain/kotlin/BuildConfig.kt similarity index 100% rename from core/src/appleMain/kotlin/BuildConfig.kt rename to common/src/appleMain/kotlin/BuildConfig.kt diff --git a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt b/common/src/appleMain/kotlin/com/powersync/PathUtils.kt similarity index 92% rename from core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt rename to common/src/appleMain/kotlin/com/powersync/PathUtils.kt index 4149231f..6c9300bb 100644 --- a/core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt +++ b/common/src/appleMain/kotlin/com/powersync/PathUtils.kt @@ -1,7 +1,6 @@ package com.powersync import kotlinx.cinterop.UnsafeNumber -import kotlinx.io.files.FileSystem import platform.Foundation.NSApplicationSupportDirectory import platform.Foundation.NSBundle import platform.Foundation.NSFileManager @@ -9,8 +8,11 @@ import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSUserDomainMask import kotlin.getValue +/** + * The default path to use for databases on Apple platforms. + */ @OptIn(UnsafeNumber::class) -internal fun appleDefaultDatabasePath(dbFilename: String): String { +public 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 diff --git a/common/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/common/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt new file mode 100644 index 00000000..53cc0039 --- /dev/null +++ b/common/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -0,0 +1,5 @@ +package com.powersync + +@ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) +public actual fun resolvePowerSyncLoadableExtensionPath(): String? = powerSyncExtensionPath diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt similarity index 100% rename from core/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt similarity index 100% rename from core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt similarity index 100% rename from core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt similarity index 100% rename from core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt similarity index 100% rename from core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt similarity index 99% rename from core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index a1eb15d2..b33cc786 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -4,7 +4,6 @@ import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase -import com.powersync.PowerSyncException import com.powersync.TestConnector import com.powersync.bucket.BucketChecksum import com.powersync.bucket.Checkpoint @@ -15,7 +14,6 @@ import com.powersync.bucket.WriteCheckpointData import com.powersync.bucket.WriteCheckpointResponse import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.connectors.PowerSyncCredentials -import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.PendingStatement import com.powersync.db.schema.PendingStatementParameter import com.powersync.db.schema.RawTable diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt similarity index 100% rename from core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncStreamTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncStreamTest.kt similarity index 100% rename from core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncStreamTest.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncStreamTest.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/MockedRemoteStorage.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/MockedRemoteStorage.kt similarity index 100% rename from core/src/commonIntegrationTest/kotlin/com/powersync/testutils/MockedRemoteStorage.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/testutils/MockedRemoteStorage.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt similarity index 97% rename from core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index fed689ff..a0fdbe91 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -7,14 +7,13 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig -import com.powersync.DatabaseDriverFactory import com.powersync.ExperimentalPowerSyncAPI +import com.powersync.PersistentDriverFactory import com.powersync.PowerSyncTestLogWriter import com.powersync.TestConnector import com.powersync.bucket.WriteCheckpointData import com.powersync.bucket.WriteCheckpointResponse import com.powersync.createPowerSyncDatabaseImpl -import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.configureSyncHttpClient @@ -30,7 +29,7 @@ import kotlinx.io.files.Path import kotlinx.serialization.json.JsonElement import kotlin.coroutines.resume -expect val factory: DatabaseDriverFactory +expect val factory: PersistentDriverFactory expect fun cleanup(path: String) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/UserRow.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/UserRow.kt similarity index 100% rename from core/src/commonIntegrationTest/kotlin/com/powersync/testutils/UserRow.kt rename to common/src/commonIntegrationTest/kotlin/com/powersync/testutils/UserRow.kt diff --git a/core/src/commonJava/kotlin/com/powersync/db/ActiveInstanceStore.commonJava.kt b/common/src/commonJava/kotlin/com/powersync/db/ActiveInstanceStore.commonJava.kt similarity index 100% rename from core/src/commonJava/kotlin/com/powersync/db/ActiveInstanceStore.commonJava.kt rename to common/src/commonJava/kotlin/com/powersync/db/ActiveInstanceStore.commonJava.kt diff --git a/core/src/commonMain/kotlin/BuildConfig.kt b/common/src/commonMain/kotlin/BuildConfig.kt similarity index 50% rename from core/src/commonMain/kotlin/BuildConfig.kt rename to common/src/commonMain/kotlin/BuildConfig.kt index 81f23796..4cec9226 100644 --- a/core/src/commonMain/kotlin/BuildConfig.kt +++ b/common/src/commonMain/kotlin/BuildConfig.kt @@ -1,4 +1,5 @@ -@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@Suppress + ("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal expect object BuildConfig { val isDebug: Boolean } diff --git a/common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt new file mode 100644 index 00000000..85692afb --- /dev/null +++ b/common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -0,0 +1,68 @@ +package com.powersync + +import androidx.sqlite.SQLiteConnection +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig + +public interface PowerSyncPlatform { + public fun openInMemoryConnection(): SQLiteConnection + + public fun configureHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient +} + +public interface PersistentDriverFactory { + public val platform: PowerSyncPlatform + + public fun resolveDefaultDatabasePath(dbFilename: String): String + + /** + * Opens a SQLite connection on [path] with [openFlags]. + * + * The connection should have the PowerSync core extension loaded. + */ + public fun openConnection( + path: String, + openFlags: Int, + ): SQLiteConnection + + public fun openConnection( + dbFilename: String, + dbDirectory: String?, + readOnly: Boolean = false, + ): SQLiteConnection { + val dbPath = + if (dbDirectory != null) { + "$dbDirectory/$dbFilename" + } else { + resolveDefaultDatabasePath(dbFilename) + } + + return openConnection( + dbPath, + if (readOnly) { + SQLITE_OPEN_READONLY + } else { + SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE + }, + ) + } +} + +/** + * Resolves a path to the loadable PowerSync core extension library. + * + * This library must be loaded on all databases using the PowerSync SDK. On platforms where the + * extension is linked statically (only watchOS at the moment), this returns `null`. + * + * When using the PowerSync SDK directly, there is no need to invoke this method. It is intended for + * configuring external database connections not managed by PowerSync to work with the PowerSync + * SDK. + */ +@ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) +public expect fun resolvePowerSyncLoadableExtensionPath(): String? + +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/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt b/common/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt rename to common/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt diff --git a/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt new file mode 100644 index 00000000..30d45980 --- /dev/null +++ b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -0,0 +1,266 @@ +package com.powersync + +import co.touchlab.kermit.Logger +import com.powersync.bucket.StreamPriority +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.driver.SingleConnectionPool +import com.powersync.db.schema.Schema +import com.powersync.sync.SyncOptions +import com.powersync.sync.SyncStatus +import com.powersync.sync.SyncStream +import com.powersync.utils.JsonParam +import com.powersync.utils.generateLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlin.coroutines.cancellation.CancellationException + +/** + * A PowerSync managed database. + * + * Use one instance per database file. + * + * Use [PowerSyncDatabase.connect] to connect to the PowerSync service, to keep the local database in sync with the remote database. + * + * All changes to local tables are automatically recorded, whether connected or not. Once connected, the changes are uploaded. + */ +public interface PowerSyncDatabase : Queries { + /** + * Indicates if the PowerSync client has been closed. + * A new client is required after a client has been closed. + */ + public val closed: Boolean + + /** + * Identifies the database client. + * This is typically the database name. + */ + public val identifier: String + + /** + * The current sync status. + */ + public val currentStatus: SyncStatus + + /** + * Replace the schema with a new version. This is for advanced use cases - typically the schema + * should just be specified once in the constructor. + * + * Cannot be used while connected - this should only be called before connect. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun updateSchema(schema: Schema) + + /** + * Suspend function that resolves when the first sync has occurred + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun waitForFirstSync() + + /** + * Suspend function that resolves when the first sync covering at least all buckets with the + * given [priority] (or a higher one, since those would be synchronized first) has completed. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun waitForFirstSync(priority: StreamPriority) + + /** + * Connect to the PowerSync service, and keep the databases in sync. + * + * The connection is automatically re-opened if it fails for any reason. + * + * Use @param [connector] to specify the [PowerSyncBackendConnector]. + * Use @param [crudThrottleMs] to specify the time between CRUD operations. Defaults to 1000ms. + * Use @param [retryDelayMs] to specify the delay between retries after failure. Defaults to 5000ms. + * Use @param [params] to specify sync parameters from the client. + * + * Example usage: + * ``` + * val params = JsonParam.Map( + * mapOf( + * "name" to JsonParam.String("John Doe"), + * "age" to JsonParam.Number(30), + * "isStudent" to JsonParam.Boolean(false) + * ) + * ) + * + * connect( + * connector = connector, + * crudThrottleMs = 2000L, + * retryDelayMs = 10000L, + * params = params + * ) + * ``` + * TODO: Internal Team - Status changes are reported on [statusStream]. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun connect( + connector: PowerSyncBackendConnector, + crudThrottleMs: Long = 1000L, + retryDelayMs: Long = 5000L, + params: Map = emptyMap(), + options: SyncOptions = SyncOptions.defaults, + ) + + /** + * Get a batch of crud data to upload. + * + * Returns null if there is no data to upload. + * + * Use this from the [PowerSyncBackendConnector.uploadData]` callback. + * + * Once the data have been successfully uploaded, call [CrudBatch.complete] before + * requesting the next batch. + * + * Use [limit] to specify the maximum number of updates to return in a single + * batch. Default is 100. + * + * This method does include transaction ids in the result, but does not group + * data by transaction. One batch may contain data from multiple transactions, + * and a single transaction may be split over multiple batches. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun getCrudBatch(limit: Int = 100): CrudBatch? + + /** + * Get the next recorded transaction to upload. + * + * Returns null if there is no data to upload. + * + * Use this from the [PowerSyncBackendConnector.uploadData] callback. + * + * Once the data have been successfully uploaded, call [CrudTransaction.complete] before + * requesting the next transaction. + * + * Unlike [getCrudBatch], this only returns data from a single transaction at a time. + * All data for the transaction is loaded into memory. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun getNextCrudTransaction(): CrudTransaction? = getCrudTransactions().firstOrNull() + + /** + * Obtains a flow emitting completed transactions with local writes against the database. + + * This is typically used from the [PowerSyncBackendConnector.uploadData] callback. + * Each entry emitted by the returned flow is a full transaction containing all local writes + * made while that transaction was active. + * + * Unlike [getNextCrudTransaction], which always returns the oldest transaction that hasn't + * been [CrudTransaction.complete]d yet, this flow can be used to collect multiple transactions. + * Calling [CrudTransaction.complete] will mark that and all prior transactions emitted by the + * flow as completed. + * + * This can be used to upload multiple transactions in a single batch, e.g with: + * + * ```Kotlin + * val batch = mutableListOf() + * var lastTx: CrudTransaction? = null + * + * database.getCrudTransactions().takeWhile { batch.size < 100 }.collect { + * batch.addAll(it.crud) + * lastTx = it + * } + * + * if (batch.isNotEmpty()) { + * uploadChanges(batch) + * lastTx!!.complete(null) + * } + * ```` + * + * If there is no local data to upload, returns an empty flow. + */ + public fun getCrudTransactions(): Flow + + /** + * Convenience method to get the current version of PowerSync. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun getPowerSyncVersion(): String + + /** + * Create a [SyncStream] instance for the given [name] and [parameters]. + * + * Use [SyncStream.subscribe] on the returned instance to subscribe to the stream. + */ + @ExperimentalPowerSyncAPI + public fun syncStream( + name: String, + parameters: Map? = null, + ): SyncStream + + /** + * Close the sync connection. + * + * Use [connect] to connect again. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun disconnect() + + /** + * Disconnect and clear the database. + * Use this when logging out. + * The database can still be queried after this is called, but the tables + * would be empty. + * + * To preserve data in local-only tables, set clearLocal to false. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun disconnectAndClear(clearLocal: Boolean = true) + + /** + * Close the database, releasing resources. + * Also disconnects any active connection. + * + * Once close is called, this database cannot be used again - a new one must be constructed. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun close() + + public companion object PowerSyncOpenFactory { + /** + * 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]. + * + * The `identifier` parameter should be a name identifying the path of the database. The + * PowerSync SDK will emit a warning if multiple databases are opened with the same + * identifier, and uses internal locks to ensure these two databases are not synced at the + * same time (which would be inefficient and can cause consistency issues). + */ + @ExperimentalPowerSyncAPI + public fun opened( + pool: SQLiteConnectionPool, + scope: CoroutineScope, + schema: Schema, + identifier: String, + logger: Logger, + ): PowerSyncDatabase { + val group = ActiveDatabaseGroup.referenceDatabase(logger, identifier) + return openedWithGroup(pool, scope, schema, logger, group) + } + + @ExperimentalPowerSyncAPI + public fun openedWithGroup( + pool: SQLiteConnectionPool, + scope: CoroutineScope, + schema: Schema, + logger: Logger, + group: Pair, + ): PowerSyncDatabase = + PowerSyncDatabaseImpl( + schema, + scope, + pool, + logger, + group, + ) + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt similarity index 96% rename from core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt rename to common/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt index 6e0556d1..6fd6cb67 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt +++ b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt @@ -20,7 +20,7 @@ public const val DEFAULT_DB_FILENAME: String = "powersync.db" @OptIn(DelicateCoroutinesApi::class) @DefaultArgumentInterop.Enabled public fun PowerSyncDatabase( - factory: DatabaseDriverFactory, + factory: PersistentDriverFactory, schema: Schema, dbFilename: String = DEFAULT_DB_FILENAME, scope: CoroutineScope = GlobalScope, @@ -45,7 +45,7 @@ public fun PowerSyncDatabase( @OptIn(ExperimentalPowerSyncAPI::class) internal fun createPowerSyncDatabaseImpl( - factory: DatabaseDriverFactory, + factory: PersistentDriverFactory, schema: Schema, dbFilename: String, scope: CoroutineScope, diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncException.kt b/common/src/commonMain/kotlin/com/powersync/PowerSyncException.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/PowerSyncException.kt rename to common/src/commonMain/kotlin/com/powersync/PowerSyncException.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/Attachment.kt b/common/src/commonMain/kotlin/com/powersync/attachments/Attachment.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/Attachment.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/Attachment.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/AttachmentQueue.kt b/common/src/commonMain/kotlin/com/powersync/attachments/AttachmentQueue.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/AttachmentQueue.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/AttachmentQueue.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/AttachmentService.kt b/common/src/commonMain/kotlin/com/powersync/attachments/AttachmentService.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/AttachmentService.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/AttachmentService.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/AttachmentTable.kt b/common/src/commonMain/kotlin/com/powersync/attachments/AttachmentTable.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/AttachmentTable.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/AttachmentTable.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/LocalStorage.kt b/common/src/commonMain/kotlin/com/powersync/attachments/LocalStorage.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/LocalStorage.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/LocalStorage.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/README.md b/common/src/commonMain/kotlin/com/powersync/attachments/README.md similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/README.md rename to common/src/commonMain/kotlin/com/powersync/attachments/README.md diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/RemoteStorage.kt b/common/src/commonMain/kotlin/com/powersync/attachments/RemoteStorage.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/RemoteStorage.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/RemoteStorage.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/SyncErrorHandler.kt b/common/src/commonMain/kotlin/com/powersync/attachments/SyncErrorHandler.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/SyncErrorHandler.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/SyncErrorHandler.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentContextImpl.kt b/common/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentContextImpl.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentContextImpl.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentContextImpl.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentServiceImpl.kt b/common/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentServiceImpl.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentServiceImpl.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/implementation/AttachmentServiceImpl.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/storage/IOLocalStorageAdapter.kt b/common/src/commonMain/kotlin/com/powersync/attachments/storage/IOLocalStorageAdapter.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/storage/IOLocalStorageAdapter.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/storage/IOLocalStorageAdapter.kt diff --git a/core/src/commonMain/kotlin/com/powersync/attachments/sync/SyncingService.kt b/common/src/commonMain/kotlin/com/powersync/attachments/sync/SyncingService.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/attachments/sync/SyncingService.kt rename to common/src/commonMain/kotlin/com/powersync/attachments/sync/SyncingService.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt b/common/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt b/common/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketState.kt b/common/src/commonMain/kotlin/com/powersync/bucket/BucketState.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/BucketState.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/BucketState.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt b/common/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt b/common/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt b/common/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt b/common/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/LocalOperationCounters.kt b/common/src/commonMain/kotlin/com/powersync/bucket/LocalOperationCounters.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/LocalOperationCounters.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/LocalOperationCounters.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/OpType.kt b/common/src/commonMain/kotlin/com/powersync/bucket/OpType.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/OpType.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/OpType.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/OplogEntry.kt b/common/src/commonMain/kotlin/com/powersync/bucket/OplogEntry.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/OplogEntry.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/OplogEntry.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/SqliteOp.kt b/common/src/commonMain/kotlin/com/powersync/bucket/SqliteOp.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/SqliteOp.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/SqliteOp.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/StreamPriority.kt b/common/src/commonMain/kotlin/com/powersync/bucket/StreamPriority.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/StreamPriority.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/StreamPriority.kt diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/WriteCheckpointResult.kt b/common/src/commonMain/kotlin/com/powersync/bucket/WriteCheckpointResult.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/bucket/WriteCheckpointResult.kt rename to common/src/commonMain/kotlin/com/powersync/bucket/WriteCheckpointResult.kt diff --git a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt b/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt rename to common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt diff --git a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncCredentials.kt b/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncCredentials.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncCredentials.kt rename to common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncCredentials.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt b/common/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt similarity index 77% rename from core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt rename to common/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt index 1ba3faed..e6d011d0 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt +++ b/common/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.sync.Mutex internal expect fun disposeWhenDeallocated(resource: ActiveDatabaseResource): Any /** - * An collection of PowerSync databases with the same path / identifier. + * A collection of PowerSync databases with the same path / identifier. * * We expect that each group will only ever have one database because we encourage users to write their databases as * singletons. We print a warning when two databases are part of the same group. @@ -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 internal constructor( + internal 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,13 @@ internal class ActiveDatabaseGroup( } } - internal open class GroupsCollection : Synchronizable() { + /** + * A collection of [ActiveDatabaseGroup]s. + * + * Typically, one uses the singleton instance that is the companion object of that class, but separate groups can be + * used for testing. + */ + public open class GroupsCollection : Synchronizable() { internal val allGroups = mutableListOf() private fun findGroup( @@ -61,7 +67,7 @@ internal class ActiveDatabaseGroup( resolvedGroup } - internal fun referenceDatabase( + public fun referenceDatabase( warnOnDuplicate: Logger, identifier: String, ): Pair { @@ -72,7 +78,7 @@ internal class ActiveDatabaseGroup( } } - companion object : GroupsCollection() { + public companion object : GroupsCollection() { internal val multipleInstancesMessage = """ Multiple PowerSync instances for the same database have been detected. @@ -82,13 +88,13 @@ internal class ActiveDatabaseGroup( } } -internal class ActiveDatabaseResource( - val group: ActiveDatabaseGroup, +public class ActiveDatabaseResource( + internal val group: ActiveDatabaseGroup, ) { - val disposed = AtomicBoolean(false) + internal val disposed = AtomicBoolean(false) - fun dispose() { - if (disposed.compareAndSet(false, true)) { + public fun dispose() { + if (disposed.compareAndSet(expected = false, new = true)) { group.removeUsage() } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/Functions.kt b/common/src/commonMain/kotlin/com/powersync/db/Functions.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/Functions.kt rename to common/src/commonMain/kotlin/com/powersync/db/Functions.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt rename to common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt b/common/src/commonMain/kotlin/com/powersync/db/Queries.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/Queries.kt rename to common/src/commonMain/kotlin/com/powersync/db/Queries.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt b/common/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt rename to common/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/StreamImpl.kt b/common/src/commonMain/kotlin/com/powersync/db/StreamImpl.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/StreamImpl.kt rename to common/src/commonMain/kotlin/com/powersync/db/StreamImpl.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudBatch.kt b/common/src/commonMain/kotlin/com/powersync/db/crud/CrudBatch.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/crud/CrudBatch.kt rename to common/src/commonMain/kotlin/com/powersync/db/crud/CrudBatch.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt b/common/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt rename to common/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudRow.kt b/common/src/commonMain/kotlin/com/powersync/db/crud/CrudRow.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/crud/CrudRow.kt rename to common/src/commonMain/kotlin/com/powersync/db/crud/CrudRow.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudTransaction.kt b/common/src/commonMain/kotlin/com/powersync/db/crud/CrudTransaction.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/crud/CrudTransaction.kt rename to common/src/commonMain/kotlin/com/powersync/db/crud/CrudTransaction.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt b/common/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt rename to common/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/UpdateType.kt b/common/src/commonMain/kotlin/com/powersync/db/crud/UpdateType.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/crud/UpdateType.kt rename to common/src/commonMain/kotlin/com/powersync/db/crud/UpdateType.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/UploadQueueStats.kt b/common/src/commonMain/kotlin/com/powersync/db/crud/UploadQueueStats.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/crud/UploadQueueStats.kt rename to common/src/commonMain/kotlin/com/powersync/db/crud/UploadQueueStats.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt b/common/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt similarity index 95% rename from core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt rename to common/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt index 682d5163..1663d359 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt +++ b/common/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt @@ -2,9 +2,8 @@ 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.PersistentDriverFactory import com.powersync.utils.JsonUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -15,7 +14,7 @@ import kotlinx.coroutines.sync.withLock @OptIn(ExperimentalPowerSyncAPI::class) internal class InternalConnectionPool( - private val factory: DatabaseDriverFactory, + private val factory: PersistentDriverFactory, private val scope: CoroutineScope, private val dbFilename: String, private val dbDirectory: String?, @@ -29,8 +28,7 @@ internal class InternalConnectionPool( private fun newConnection(readOnly: Boolean): SQLiteConnection { val connection = - openDatabase( - factory = factory, + factory.openConnection( dbFilename = dbFilename, dbDirectory = dbDirectory, readOnly = false, diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/LazyPool.kt b/common/src/commonMain/kotlin/com/powersync/db/driver/LazyPool.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/driver/LazyPool.kt rename to common/src/commonMain/kotlin/com/powersync/db/driver/LazyPool.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt b/common/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt rename to common/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt b/common/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt rename to common/src/commonMain/kotlin/com/powersync/db/driver/ReadPool.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt b/common/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt rename to common/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt b/common/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt similarity index 95% rename from core/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt rename to common/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt index 41337f96..4a9c2b36 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt +++ b/common/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.sync.withLock * This does not provide any concurrency, but is still a reasonable implementation to use for e.g. tests. */ @OptIn(ExperimentalPowerSyncAPI::class) -internal class SingleConnectionPool( +public class SingleConnectionPool( private val conn: SQLiteConnection, ) : SQLiteConnectionPool { private val mutex: Mutex = Mutex() @@ -42,9 +42,8 @@ internal class SingleConnectionPool( override suspend fun withAllConnections( action: suspend (writer: SQLiteConnectionLease, readers: List) -> R, - ) = write { writer -> + ): Unit = write { writer -> action(writer, emptyList()) - Unit } override val updates: SharedFlow> diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt b/common/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt rename to common/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabase.kt b/common/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabase.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabase.kt rename to common/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabase.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt b/common/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt rename to common/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalTable.kt b/common/src/commonMain/kotlin/com/powersync/db/internal/InternalTable.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/internal/InternalTable.kt rename to common/src/commonMain/kotlin/com/powersync/db/internal/InternalTable.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt b/common/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt rename to common/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncVersion.kt b/common/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncVersion.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncVersion.kt rename to common/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncVersion.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/BaseTable.kt b/common/src/commonMain/kotlin/com/powersync/db/schema/BaseTable.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/schema/BaseTable.kt rename to common/src/commonMain/kotlin/com/powersync/db/schema/BaseTable.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/Column.kt b/common/src/commonMain/kotlin/com/powersync/db/schema/Column.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/schema/Column.kt rename to common/src/commonMain/kotlin/com/powersync/db/schema/Column.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/ColumnType.kt b/common/src/commonMain/kotlin/com/powersync/db/schema/ColumnType.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/schema/ColumnType.kt rename to common/src/commonMain/kotlin/com/powersync/db/schema/ColumnType.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/Index.kt b/common/src/commonMain/kotlin/com/powersync/db/schema/Index.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/schema/Index.kt rename to common/src/commonMain/kotlin/com/powersync/db/schema/Index.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/IndexedColumn.kt b/common/src/commonMain/kotlin/com/powersync/db/schema/IndexedColumn.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/schema/IndexedColumn.kt rename to common/src/commonMain/kotlin/com/powersync/db/schema/IndexedColumn.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/RawTable.kt b/common/src/commonMain/kotlin/com/powersync/db/schema/RawTable.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/schema/RawTable.kt rename to common/src/commonMain/kotlin/com/powersync/db/schema/RawTable.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/Schema.kt b/common/src/commonMain/kotlin/com/powersync/db/schema/Schema.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/schema/Schema.kt rename to common/src/commonMain/kotlin/com/powersync/db/schema/Schema.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt b/common/src/commonMain/kotlin/com/powersync/db/schema/Table.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt rename to common/src/commonMain/kotlin/com/powersync/db/schema/Table.kt diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/validation.kt b/common/src/commonMain/kotlin/com/powersync/db/schema/validation.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/db/schema/validation.kt rename to common/src/commonMain/kotlin/com/powersync/db/schema/validation.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt b/common/src/commonMain/kotlin/com/powersync/sync/Instruction.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt rename to common/src/commonMain/kotlin/com/powersync/sync/Instruction.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt b/common/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt rename to common/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt b/common/src/commonMain/kotlin/com/powersync/sync/Progress.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/Progress.kt rename to common/src/commonMain/kotlin/com/powersync/sync/Progress.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Stream.kt b/common/src/commonMain/kotlin/com/powersync/sync/Stream.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/Stream.kt rename to common/src/commonMain/kotlin/com/powersync/sync/Stream.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt rename to common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt rename to common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt b/common/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt rename to common/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncLine.kt b/common/src/commonMain/kotlin/com/powersync/sync/SyncLine.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/SyncLine.kt rename to common/src/commonMain/kotlin/com/powersync/sync/SyncLine.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncLocalDatabaseResult.kt b/common/src/commonMain/kotlin/com/powersync/sync/SyncLocalDatabaseResult.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/SyncLocalDatabaseResult.kt rename to common/src/commonMain/kotlin/com/powersync/sync/SyncLocalDatabaseResult.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/common/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt rename to common/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt b/common/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt rename to common/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/UserAgent.kt b/common/src/commonMain/kotlin/com/powersync/sync/UserAgent.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/sync/UserAgent.kt rename to common/src/commonMain/kotlin/com/powersync/sync/UserAgent.kt diff --git a/core/src/commonMain/kotlin/com/powersync/utils/AtomicMutableSet.kt b/common/src/commonMain/kotlin/com/powersync/utils/AtomicMutableSet.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/utils/AtomicMutableSet.kt rename to common/src/commonMain/kotlin/com/powersync/utils/AtomicMutableSet.kt diff --git a/core/src/commonMain/kotlin/com/powersync/utils/Json.kt b/common/src/commonMain/kotlin/com/powersync/utils/Json.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/utils/Json.kt rename to common/src/commonMain/kotlin/com/powersync/utils/Json.kt diff --git a/core/src/commonMain/kotlin/com/powersync/utils/Log.kt b/common/src/commonMain/kotlin/com/powersync/utils/Log.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/utils/Log.kt rename to common/src/commonMain/kotlin/com/powersync/utils/Log.kt diff --git a/core/src/commonMain/kotlin/com/powersync/utils/Strings.kt b/common/src/commonMain/kotlin/com/powersync/utils/Strings.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/utils/Strings.kt rename to common/src/commonMain/kotlin/com/powersync/utils/Strings.kt diff --git a/core/src/commonMain/kotlin/com/powersync/utils/ThrottleFlow.kt b/common/src/commonMain/kotlin/com/powersync/utils/ThrottleFlow.kt similarity index 100% rename from core/src/commonMain/kotlin/com/powersync/utils/ThrottleFlow.kt rename to common/src/commonMain/kotlin/com/powersync/utils/ThrottleFlow.kt diff --git a/core/src/jvmMain/kotlin/BuildConfig.kt b/common/src/jvmMain/kotlin/BuildConfig.kt similarity index 89% rename from core/src/jvmMain/kotlin/BuildConfig.kt rename to common/src/jvmMain/kotlin/BuildConfig.kt index 86924a14..6350a13c 100644 --- a/core/src/jvmMain/kotlin/BuildConfig.kt +++ b/common/src/jvmMain/kotlin/BuildConfig.kt @@ -8,5 +8,5 @@ internal actual object BuildConfig { */ actual val isDebug: Boolean = System.getProperty("com.powersync.debug") == "true" || - System.getenv("POWERSYNC_JVM_DEBUG") == "true" + System.getenv("POWERSYNC_JVM_DEBUG") == "true" } diff --git a/common/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/common/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt new file mode 100644 index 00000000..2ecb6741 --- /dev/null +++ b/common/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -0,0 +1,9 @@ +package com.powersync + +import com.powersync.db.runWrapped + +@ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) +public actual fun resolvePowerSyncLoadableExtensionPath(): String? = runWrapped { powersyncExtension } + +private val powersyncExtension: String by lazy { extractLib("powersync") } diff --git a/core/src/jvmMain/kotlin/com/powersync/ExtractLib.kt b/common/src/jvmMain/kotlin/com/powersync/ExtractLib.kt similarity index 100% rename from core/src/jvmMain/kotlin/com/powersync/ExtractLib.kt rename to common/src/jvmMain/kotlin/com/powersync/ExtractLib.kt diff --git a/core/src/jvmMain/kotlin/com/powersync/sync/UserAgent.jvm.kt b/common/src/jvmMain/kotlin/com/powersync/sync/UserAgent.jvm.kt similarity index 100% rename from core/src/jvmMain/kotlin/com/powersync/sync/UserAgent.jvm.kt rename to common/src/jvmMain/kotlin/com/powersync/sync/UserAgent.jvm.kt diff --git a/core/src/nativeMain/interop/sqlite3.def b/common/src/nativeMain/interop/sqlite3.def similarity index 100% rename from core/src/nativeMain/interop/sqlite3.def rename to common/src/nativeMain/interop/sqlite3.def diff --git a/core/src/nativeMain/interop/sqlite3.h b/common/src/nativeMain/interop/sqlite3.h similarity index 100% rename from core/src/nativeMain/interop/sqlite3.h rename to common/src/nativeMain/interop/sqlite3.h diff --git a/core/src/nativeMain/kotlin/com/powersync/db/ActiveInstanceStore.native.kt b/common/src/nativeMain/kotlin/com/powersync/db/ActiveInstanceStore.native.kt similarity index 100% rename from core/src/nativeMain/kotlin/com/powersync/db/ActiveInstanceStore.native.kt rename to common/src/nativeMain/kotlin/com/powersync/db/ActiveInstanceStore.native.kt diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt b/common/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt similarity index 96% rename from core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt rename to common/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt index a08bf52a..27b8c9f4 100644 --- a/core/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt +++ b/common/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,10 +52,10 @@ internal class Database( Statement(sql, ptr, stmtPtr.value!!) } - fun loadExtension( + public fun loadExtension( filename: String, entrypoint: String, - ) = memScoped { + ): Unit = memScoped { val errorMessagePointer = alloc>() val resultCode = sqlite3_load_extension(ptr, filename, entrypoint, errorMessagePointer.ptr) @@ -79,8 +79,8 @@ internal class Database( } } - companion object { - fun open( + public companion object { + public fun open( path: String, flags: Int, ): Database = diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt b/common/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt similarity index 100% rename from core/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt rename to common/src/nativeMain/kotlin/com/powersync/sqlite/SqliteException.kt diff --git a/core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt b/common/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt similarity index 100% rename from core/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt rename to common/src/nativeMain/kotlin/com/powersync/sqlite/Statement.kt diff --git a/core/src/nativeMain/kotlin/com/powersync/sync/UserAgent.native.kt b/common/src/nativeMain/kotlin/com/powersync/sync/UserAgent.native.kt similarity index 100% rename from core/src/nativeMain/kotlin/com/powersync/sync/UserAgent.native.kt rename to common/src/nativeMain/kotlin/com/powersync/sync/UserAgent.native.kt diff --git a/core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt b/common/src/nativeTest/kotlin/com.powersync.sqlite/DatabaseTest.kt similarity index 100% rename from core/src/appleTest/kotlin/com/powersync/sqlite/DatabaseTest.kt rename to common/src/nativeTest/kotlin/com.powersync.sqlite/DatabaseTest.kt diff --git a/core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt b/common/src/nativeTest/kotlin/com.powersync.sqlite/StatementTest.kt similarity index 100% rename from core/src/appleTest/kotlin/com/powersync/sqlite/StatementTest.kt rename to common/src/nativeTest/kotlin/com.powersync.sqlite/StatementTest.kt diff --git a/common/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/common/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt new file mode 100644 index 00000000..5758700f --- /dev/null +++ b/common/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -0,0 +1,25 @@ +package com.powersync + +import com.powersync.static.powersync_init_static + +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 +} + +@ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) +public actual fun resolvePowerSyncLoadableExtensionPath(): String? { + didLoadExtension + return null +} diff --git a/common/src/watchosMain/powersync_static.h b/common/src/watchosMain/powersync_static.h new file mode 100644 index 00000000..9a1d3560 --- /dev/null +++ b/common/src/watchosMain/powersync_static.h @@ -0,0 +1 @@ +int powersync_init_static(); diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 77ca60b4..eeb742fb 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,14 +1,8 @@ import com.powersync.plugins.utils.powersyncTargets -import de.undercouch.gradle.tasks.download.Download import org.gradle.api.tasks.testing.logging.TestExceptionFormat -import org.gradle.internal.os.OperatingSystem import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest import org.jetbrains.kotlin.gradle.tasks.KotlinTest -import org.jetbrains.kotlin.konan.target.Family -import java.nio.file.Path -import kotlin.io.path.createDirectories -import kotlin.io.path.writeText plugins { alias(libs.plugins.kotlinMultiplatform) @@ -24,82 +18,6 @@ plugins { id("dokka-convention") } -val binariesFolder = project.layout.buildDirectory.dir("binaries/desktop") -val downloadPowersyncDesktopBinaries by tasks.registering(Download::class) { - description = "Download PowerSync core extensions for JVM builds and releases" - - val coreVersion = - libs.versions.powersync.core - .get() - val linux_aarch64 = - "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/libpowersync_aarch64.so" - val linux_x64 = - "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/libpowersync_x64.so" - val macos_aarch64 = - "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/libpowersync_aarch64.dylib" - val macos_x64 = - "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/libpowersync_x64.dylib" - val windows_x64 = - "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/powersync_x64.dll" - - val includeAllPlatformsForJvmBuild = - project.findProperty("powersync.binaries.allPlatforms") == "true" - val os = OperatingSystem.current() - - // The jar we're releasing for JVM clients needs to include the core extension. For local tests, it's enough to only - // download the extension for the OS running the build. For releases, we want to include them all. - // We're not compiling native code for JVM builds here (we're doing that for Android only), so we just have to - // fetch prebuilt binaries from the powersync-sqlite-core repository. - if (includeAllPlatformsForJvmBuild) { - src(listOf(linux_aarch64, linux_x64, macos_aarch64, macos_x64, windows_x64)) - } else { - val (aarch64, x64) = - when { - os.isLinux -> linux_aarch64 to linux_x64 - os.isMacOsX -> macos_aarch64 to macos_x64 - os.isWindows -> null to windows_x64 - else -> error("Unknown operating system: $os") - } - val arch = System.getProperty("os.arch") - src( - when (arch) { - "aarch64" -> listOfNotNull(aarch64) - "amd64", "x86_64" -> listOfNotNull(x64) - else -> error("Unsupported architecture: $arch") - }, - ) - } - dest(binariesFolder.map { it.dir("powersync") }) - onlyIfModified(true) -} - -val generateVersionConstant by tasks.registering { - val target = project.layout.buildDirectory.dir("generated/constants") - val packageName = "com.powersync.build" - - outputs.dir(target) - val currentVersion = version.toString() - - doLast { - val dir = target.get().asFile - dir.mkdir() - val rootPath = dir.toPath() - - val source = - """ - package $packageName - - internal const val LIBRARY_VERSION: String = "$currentVersion" - - """.trimIndent() - - val packageRoot = packageName.split('.').fold(rootPath, Path::resolve) - packageRoot.createDirectories() - - packageRoot.resolve("BuildConstants.kt").writeText(source) - } -} - kotlin { powersyncTargets() @@ -108,21 +26,6 @@ kotlin { compileTaskProvider { compilerOptions.freeCompilerArgs.add("-Xexport-kdoc") } - - if (target.konanTarget.family == Family.WATCHOS) { - // We're linking the core extension statically, which means that we need a cinterop - // to call powersync_init_static - cinterops.create("powersync_static") { - packageName("com.powersync.static") - 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")) - } } } @@ -142,34 +45,13 @@ kotlin { dependsOn(commonTest.get()) } - val commonJava by creating { - dependsOn(commonMain.get()) - } - commonMain.configure { - kotlin { - srcDir(generateVersionConstant) - } - dependencies { - api(libs.androidx.sqlite.sqlite) - - implementation(libs.uuid) - implementation(libs.kotlin.stdlib) - implementation(libs.ktor.client.contentnegotiation) - implementation(libs.ktor.serialization.json) - implementation(libs.kotlinx.io) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.datetime) - implementation(libs.stately.concurrency) - implementation(libs.configuration.annotations) - api(libs.ktor.client.core) - api(libs.kermit) + api(projects.common) } } androidMain { - dependsOn(commonJava) dependencies { api(libs.powersync.sqlite.core.android) implementation(libs.ktor.client.okhttp) @@ -178,8 +60,6 @@ kotlin { } jvmMain { - dependsOn(commonJava) - dependencies { implementation(libs.ktor.client.okhttp) implementation(libs.androidx.sqlite.bundled) @@ -259,10 +139,6 @@ android { ndkVersion = "27.1.12297006" } -tasks.named(kotlin.jvm().compilations["main"].processResourcesTaskName) { - from(downloadPowersyncDesktopBinaries) -} - // We want to build with recent JDKs, but need to make sure we support Java 8. https://jakewharton.com/build-on-latest-java-test-through-lowest-java/ val testWithJava8 by tasks.registering(KotlinJvmTest::class) { javaLauncher = @@ -291,5 +167,5 @@ tasks.withType { } dokka { - moduleName.set("PowerSync Core") + moduleName.set("PowerSync") } diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 8ee2b28f..16542735 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -8,12 +8,15 @@ import kotlin.Throws @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public actual class DatabaseDriverFactory( private val context: Context, -) { +): PersistentDriverFactory { private val driver = BundledSQLiteDriver().also { it.addPowerSyncExtension() } - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = context.getDatabasePath(dbFilename).path + actual override val platform: PowerSyncPlatform + get() = BuiltinPlatform - internal actual fun openConnection( + actual override fun resolveDefaultDatabasePath(dbFilename: String): String = context.getDatabasePath(dbFilename).path + + actual override fun openConnection( path: String, openFlags: Int, ): SQLiteConnection = driver.open(path, openFlags) @@ -23,8 +26,4 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension("libpowersync.so", "sqlite3_powersync_init") } -@ExperimentalPowerSyncAPI -@Throws(PowerSyncException::class) -public actual fun resolvePowerSyncLoadableExtensionPath(): String? = "libpowersync.so" - internal actual fun openInMemoryConnection(): SQLiteConnection = BundledSQLiteDriver().also { it.addPowerSyncExtension() }.open(":memory:") diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index 54d2033b..89d76c6d 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -4,16 +4,20 @@ import androidx.sqlite.SQLiteConnection import com.powersync.sqlite.Database @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) -public actual class DatabaseDriverFactory { - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) +public actual class DatabaseDriverFactory: PersistentDriverFactory { + actual override val platform: PowerSyncPlatform + get() = BuiltinPlatform - internal actual fun openConnection( + actual override fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) + + @OptIn(ExperimentalPowerSyncAPI::class) + actual override fun openConnection( path: String, openFlags: Int, ): SQLiteConnection { val db = Database.open(path, openFlags) try { - db.loadExtension(powerSyncExtensionPath, "sqlite3_powersync_init") + db.loadExtension(resolvePowerSyncLoadableExtensionPath()!!, "sqlite3_powersync_init") } catch (e: PowerSyncException) { db.close() throw e @@ -22,8 +26,4 @@ public actual class DatabaseDriverFactory { } } -@ExperimentalPowerSyncAPI -@Throws(PowerSyncException::class) -public actual fun resolvePowerSyncLoadableExtensionPath(): String? = powerSyncExtensionPath - internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02) diff --git a/core/src/appleTest/kotlin/com/powersync/DatabaseDriverFactoryTest.kt b/core/src/appleTest/kotlin/com/powersync/DatabaseDriverFactoryTest.kt index e3052ffe..d383feb3 100644 --- a/core/src/appleTest/kotlin/com/powersync/DatabaseDriverFactoryTest.kt +++ b/core/src/appleTest/kotlin/com/powersync/DatabaseDriverFactoryTest.kt @@ -4,13 +4,13 @@ import kotlin.experimental.ExperimentalNativeApi import kotlin.test.Test class DatabaseDriverFactoryTest { - @OptIn(ExperimentalNativeApi::class) + @OptIn(ExperimentalNativeApi::class, ExperimentalPowerSyncAPI::class) @Test fun findsPowerSyncFramework() { 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. - powerSyncExtensionPath + resolvePowerSyncLoadableExtensionPath()!! } } } diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 18698c95..acfdd99e 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -1,62 +1,23 @@ package com.powersync import androidx.sqlite.SQLiteConnection +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -public expect class DatabaseDriverFactory { - internal fun resolveDefaultDatabasePath(dbFilename: String): String +public expect class DatabaseDriverFactory: PersistentDriverFactory { + override val platform: PowerSyncPlatform - /** - * 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 + override fun resolveDefaultDatabasePath(dbFilename: String): String + override fun openConnection(path: String, openFlags: Int): SQLiteConnection } internal expect fun openInMemoryConnection(): SQLiteConnection -/** - * Resolves a path to the loadable PowerSync core extension library. - * - * This library must be loaded on all databases using the PowerSync SDK. On platforms where the - * extension is linked statically (only watchOS at the moment), this returns `null`. - * - * When using the PowerSync SDK directly, there is no need to invoke this method. It is intended for - * configuring external database connections not managed by PowerSync to work with the PowerSync - * SDK. - */ -@ExperimentalPowerSyncAPI -@Throws(PowerSyncException::class) -public expect fun resolvePowerSyncLoadableExtensionPath(): String? +internal object BuiltinPlatform: PowerSyncPlatform { + override fun openInMemoryConnection(): SQLiteConnection { + return com.powersync.openInMemoryConnection() + } -@OptIn(ExperimentalPowerSyncAPI::class) -internal fun openDatabase( - factory: DatabaseDriverFactory, - dbFilename: String, - dbDirectory: String?, - readOnly: Boolean = false, -): SQLiteConnection { - val dbPath = - if (dbDirectory != null) { - "$dbDirectory/$dbFilename" - } else { - factory.resolveDefaultDatabasePath(dbFilename) - } - - return factory.openConnection( - dbPath, - if (readOnly) { - SQLITE_OPEN_READONLY - } else { - SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE - }, - ) + override fun configureHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(block) } - -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/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index bdf8e66b..a750a771 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -1,289 +1,31 @@ package com.powersync import co.touchlab.kermit.Logger -import com.powersync.bucket.StreamPriority -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.driver.SingleConnectionPool import com.powersync.db.schema.Schema -import com.powersync.sync.SyncOptions -import com.powersync.sync.SyncStatus -import com.powersync.sync.SyncStream -import com.powersync.utils.JsonParam import com.powersync.utils.generateLogger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.firstOrNull -import kotlin.coroutines.cancellation.CancellationException /** - * A PowerSync managed database. - * - * Use one instance per database file. - * - * Use [PowerSyncDatabase.connect] to connect to the PowerSync service, to keep the local database in sync with the remote database. - * - * All changes to local tables are automatically recorded, whether connected or not. Once connected, the changes are uploaded. + * Creates an in-memory PowerSync database instance, useful for testing. */ -public interface PowerSyncDatabase : Queries { - /** - * Indicates if the PowerSync client has been closed. - * A new client is required after a client has been closed. - */ - public val closed: Boolean - - /** - * Identifies the database client. - * This is typically the database name. - */ - public val identifier: String - - /** - * The current sync status. - */ - public val currentStatus: SyncStatus - - /** - * Replace the schema with a new version. This is for advanced use cases - typically the schema - * should just be specified once in the constructor. - * - * Cannot be used while connected - this should only be called before connect. - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun updateSchema(schema: Schema) - - /** - * Suspend function that resolves when the first sync has occurred - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun waitForFirstSync() - - /** - * Suspend function that resolves when the first sync covering at least all buckets with the - * given [priority] (or a higher one, since those would be synchronized first) has completed. - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun waitForFirstSync(priority: StreamPriority) - - /** - * Connect to the PowerSync service, and keep the databases in sync. - * - * The connection is automatically re-opened if it fails for any reason. - * - * Use @param [connector] to specify the [PowerSyncBackendConnector]. - * Use @param [crudThrottleMs] to specify the time between CRUD operations. Defaults to 1000ms. - * Use @param [retryDelayMs] to specify the delay between retries after failure. Defaults to 5000ms. - * Use @param [params] to specify sync parameters from the client. - * - * Example usage: - * ``` - * val params = JsonParam.Map( - * mapOf( - * "name" to JsonParam.String("John Doe"), - * "age" to JsonParam.Number(30), - * "isStudent" to JsonParam.Boolean(false) - * ) - * ) - * - * connect( - * connector = connector, - * crudThrottleMs = 2000L, - * retryDelayMs = 10000L, - * params = params - * ) - * ``` - * TODO: Internal Team - Status changes are reported on [statusStream]. - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun connect( - connector: PowerSyncBackendConnector, - crudThrottleMs: Long = 1000L, - retryDelayMs: Long = 5000L, - params: Map = emptyMap(), - options: SyncOptions = SyncOptions.defaults, +@OptIn(ExperimentalPowerSyncAPI::class) +public fun PowerSyncDatabase.PowerSyncOpenFactory.inMemory( + schema: Schema, + scope: CoroutineScope, + logger: Logger? = null, +): PowerSyncDatabase { + val logger = generateLogger(logger) + // Since this returns a fresh in-memory database every time, use a fresh group to avoid warnings about the + // same database being opened multiple times. + val collection = ActiveDatabaseGroup.GroupsCollection().referenceDatabase(logger, "test") + + return openedWithGroup( + SingleConnectionPool(openInMemoryConnection()), + scope, + schema, + logger, + collection, ) - - /** - * Get a batch of crud data to upload. - * - * Returns null if there is no data to upload. - * - * Use this from the [PowerSyncBackendConnector.uploadData]` callback. - * - * Once the data have been successfully uploaded, call [CrudBatch.complete] before - * requesting the next batch. - * - * Use [limit] to specify the maximum number of updates to return in a single - * batch. Default is 100. - * - * This method does include transaction ids in the result, but does not group - * data by transaction. One batch may contain data from multiple transactions, - * and a single transaction may be split over multiple batches. - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun getCrudBatch(limit: Int = 100): CrudBatch? - - /** - * Get the next recorded transaction to upload. - * - * Returns null if there is no data to upload. - * - * Use this from the [PowerSyncBackendConnector.uploadData] callback. - * - * Once the data have been successfully uploaded, call [CrudTransaction.complete] before - * requesting the next transaction. - * - * Unlike [getCrudBatch], this only returns data from a single transaction at a time. - * All data for the transaction is loaded into memory. - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun getNextCrudTransaction(): CrudTransaction? = getCrudTransactions().firstOrNull() - - /** - * Obtains a flow emitting completed transactions with local writes against the database. - - * This is typically used from the [PowerSyncBackendConnector.uploadData] callback. - * Each entry emitted by the returned flow is a full transaction containing all local writes - * made while that transaction was active. - * - * Unlike [getNextCrudTransaction], which always returns the oldest transaction that hasn't - * been [CrudTransaction.complete]d yet, this flow can be used to collect multiple transactions. - * Calling [CrudTransaction.complete] will mark that and all prior transactions emitted by the - * flow as completed. - * - * This can be used to upload multiple transactions in a single batch, e.g with: - * - * ```Kotlin - * val batch = mutableListOf() - * var lastTx: CrudTransaction? = null - * - * database.getCrudTransactions().takeWhile { batch.size < 100 }.collect { - * batch.addAll(it.crud) - * lastTx = it - * } - * - * if (batch.isNotEmpty()) { - * uploadChanges(batch) - * lastTx!!.complete(null) - * } - * ```` - * - * If there is no local data to upload, returns an empty flow. - */ - public fun getCrudTransactions(): Flow - - /** - * Convenience method to get the current version of PowerSync. - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun getPowerSyncVersion(): String - - /** - * Create a [SyncStream] instance for the given [name] and [parameters]. - * - * Use [SyncStream.subscribe] on the returned instance to subscribe to the stream. - */ - @ExperimentalPowerSyncAPI - public fun syncStream( - name: String, - parameters: Map? = null, - ): SyncStream - - /** - * Close the sync connection. - * - * Use [connect] to connect again. - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun disconnect() - - /** - * Disconnect and clear the database. - * Use this when logging out. - * The database can still be queried after this is called, but the tables - * would be empty. - * - * To preserve data in local-only tables, set clearLocal to false. - */ - @Throws(PowerSyncException::class, CancellationException::class) - public suspend fun disconnectAndClear(clearLocal: Boolean = true) - - /** - * Close the database, releasing resources. - * Also disconnects any active connection. - * - * Once close is called, this database cannot be used again - a new one must be constructed. - */ - @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]. - * - * The `identifier` parameter should be a name identifying the path of the database. The - * PowerSync SDK will emit a warning if multiple databases are opened with the same - * identifier, and uses internal locks to ensure these two databases are not synced at the - * same time (which would be inefficient and can cause consistency issues). - */ - @ExperimentalPowerSyncAPI - public fun opened( - pool: SQLiteConnectionPool, - scope: CoroutineScope, - schema: Schema, - identifier: String, - logger: Logger, - ): PowerSyncDatabase { - val group = ActiveDatabaseGroup.referenceDatabase(logger, identifier) - return openedWithGroup(pool, scope, schema, logger, group) - } - - /** - * Creates an in-memory PowerSync database instance, useful for testing. - */ - @OptIn(ExperimentalPowerSyncAPI::class) - public fun inMemory( - schema: Schema, - scope: CoroutineScope, - logger: Logger? = null, - ): PowerSyncDatabase { - val logger = generateLogger(logger) - // Since this returns a fresh in-memory database every time, use a fresh group to avoid warnings about the - // same database being opened multiple times. - val collection = ActiveDatabaseGroup.GroupsCollection().referenceDatabase(logger, "test") - - return openedWithGroup( - SingleConnectionPool(openInMemoryConnection()), - scope, - schema, - logger, - collection, - ) - } - - @ExperimentalPowerSyncAPI - internal fun openedWithGroup( - pool: SQLiteConnectionPool, - scope: CoroutineScope, - schema: Schema, - logger: Logger, - group: Pair, - ): PowerSyncDatabase = - PowerSyncDatabaseImpl( - schema, - scope, - pool, - logger, - group, - ) - } } diff --git a/core/src/commonTest/kotlin/com/powersync/db/FunctionTest.kt b/core/src/commonTest/kotlin/com/powersync/db/FunctionTest.kt index fec0a31d..fc5089a9 100644 --- a/core/src/commonTest/kotlin/com/powersync/db/FunctionTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/db/FunctionTest.kt @@ -1,6 +1,5 @@ package com.powersync.db -import com.powersync.PowerSyncException import io.kotest.assertions.throwables.shouldThrow import kotlinx.coroutines.CancellationException import kotlin.test.Test diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 3467e699..56b7d707 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -1,30 +1,26 @@ package com.powersync import androidx.sqlite.SQLiteConnection -import androidx.sqlite.driver.bundled.BundledSQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import com.powersync.db.runWrapped @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") -public actual class DatabaseDriverFactory { +public actual class DatabaseDriverFactory: PersistentDriverFactory { + actual override val platform: PowerSyncPlatform + get() = BuiltinPlatform + private val driver = BundledSQLiteDriver().also { it.addPowerSyncExtension() } - internal actual fun resolveDefaultDatabasePath(dbFilename: String): String = dbFilename + actual override fun resolveDefaultDatabasePath(dbFilename: String): String = dbFilename - internal actual fun openConnection( + actual override fun openConnection( path: String, openFlags: Int, ): SQLiteConnection = driver.open(path, openFlags) } +@OptIn(ExperimentalPowerSyncAPI::class) public fun BundledSQLiteDriver.addPowerSyncExtension() { - addExtension(powersyncExtension, "sqlite3_powersync_init") + addExtension(resolvePowerSyncLoadableExtensionPath()!!, "sqlite3_powersync_init") } -private val powersyncExtension: String by lazy { extractLib("powersync") } - -@ExperimentalPowerSyncAPI -@Throws(PowerSyncException::class) -public actual fun resolvePowerSyncLoadableExtensionPath(): String? = runWrapped { powersyncExtension } - internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02) diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index 29bd81ee..2dcc7e1f 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -5,38 +5,21 @@ 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 class DatabaseDriverFactory: PersistentDriverFactory { + actual override val platform: PowerSyncPlatform + get() = BuiltinPlatform - internal actual fun openConnection( + actual override fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) + + @OptIn(ExperimentalPowerSyncAPI::class) + actual override fun openConnection( path: String, openFlags: Int, ): SQLiteConnection { - didLoadExtension - return Database.open(path, openFlags) - } -} + resolvePowerSyncLoadableExtensionPath() -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", - ), - ) + return Database.open(path, openFlags) } - - true -} - -@ExperimentalPowerSyncAPI -@Throws(PowerSyncException::class) -public actual fun resolvePowerSyncLoadableExtensionPath(): String? { - didLoadExtension - return null } internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02) diff --git a/settings.gradle.kts b/settings.gradle.kts index 3a0d5b12..4621c775 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,6 +30,7 @@ rootProject.name = "powersync-root" include(":internal:download-core-extension") include(":internal:PowerSyncKotlin") +include(":common") include(":core") include(":core-tests-android") include(":integrations:room") From 97e7c1bde74a155ea9745da843c229eabadc533d Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 17 Oct 2025 11:18:42 +0200 Subject: [PATCH 30/41] Share common logic in test --- common/build.gradle.kts | 8 +-- .../kotlin/com/powersync/AttachmentsTest.kt | 2 +- .../kotlin/com/powersync/DatabaseTest.kt | 6 +-- .../com/powersync/sync/SyncIntegrationTest.kt | 5 +- .../com/powersync/testutils/TestUtils.kt | 17 +++--- .../powersync/testutils/MockSyncService.kt | 0 core/build.gradle.kts | 9 +--- .../kotlin/com/powersync/db/InMemoryTest.kt | 7 +-- .../powersync/sync/StreamingSyncClientTest.kt | 1 - internal/testutils/build.gradle.kts | 54 +++++++++++++++++++ .../powersync/test}/PowerSyncTestLogWriter.kt | 10 ++-- .../com/powersync/test}/TestConnector.kt | 3 +- .../kotlin/com/powersync/test}/WaitFor.kt | 4 +- .../kotlin/com/powersync/test/TestPlatform.kt | 18 +++++++ settings.gradle.kts | 1 + 15 files changed, 102 insertions(+), 43 deletions(-) rename {core => common}/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt (100%) rename {common => core}/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt (92%) create mode 100644 internal/testutils/build.gradle.kts rename {core/src/commonTest/kotlin/com/powersync => internal/testutils/src/commonMain/kotlin/com/powersync/test}/PowerSyncTestLogWriter.kt (77%) rename {core/src/commonTest/kotlin/com/powersync => internal/testutils/src/commonMain/kotlin/com/powersync/test}/TestConnector.kt (92%) rename {core/src/commonTest/kotlin/com/powersync/testutils => internal/testutils/src/commonMain/kotlin/com/powersync/test}/WaitFor.kt (89%) create mode 100644 internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 207b0518..b1a2c3cd 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -205,13 +205,7 @@ kotlin { tvosMain.orNull?.dependsOn(appleNonWatchOsMain) commonTest.dependencies { - implementation(libs.kotlin.test) - implementation(libs.test.coroutines) - implementation(libs.test.turbine) - implementation(libs.test.kotest.assertions) - implementation(libs.kermit.test) - implementation(libs.ktor.client.mock) - implementation(libs.test.turbine) + implementation(projects.internal.testutils) } // We're putting the native libraries into our JAR, so integration tests for the JVM can run as part of the unit diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt index 92aafdb7..ae042074 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt @@ -16,7 +16,7 @@ import com.powersync.testutils.ActiveDatabaseTest import com.powersync.testutils.MockedRemoteStorage import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest -import com.powersync.testutils.getTempDir +import com.powersync.test.getTempDir import dev.mokkery.answering.throws import dev.mokkery.everySuspend import dev.mokkery.matcher.ArgMatchersScope diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 97eee155..726eb318 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -10,9 +10,9 @@ import com.powersync.db.getString import com.powersync.db.schema.Schema import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest -import com.powersync.testutils.getTempDir -import com.powersync.testutils.isIOS -import com.powersync.testutils.waitFor +import com.powersync.test.getTempDir +import com.powersync.test.isIOS +import com.powersync.test.waitFor import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index b33cc786..cfe2e57a 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -4,7 +4,8 @@ import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase -import com.powersync.TestConnector +import com.powersync.PowerSyncException +import com.powersync.test.TestConnector import com.powersync.bucket.BucketChecksum import com.powersync.bucket.Checkpoint import com.powersync.bucket.OpType @@ -14,10 +15,12 @@ import com.powersync.bucket.WriteCheckpointData import com.powersync.bucket.WriteCheckpointResponse import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.connectors.PowerSyncCredentials +import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.PendingStatement import com.powersync.db.schema.PendingStatementParameter import com.powersync.db.schema.RawTable import com.powersync.db.schema.Schema +import com.powersync.test.waitFor import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest import com.powersync.testutils.waitFor diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index a0fdbe91..fd218ab3 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -8,15 +8,18 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import com.powersync.ExperimentalPowerSyncAPI -import com.powersync.PersistentDriverFactory -import com.powersync.PowerSyncTestLogWriter -import com.powersync.TestConnector +import com.powersync.test.PowerSyncTestLogWriter +import com.powersync.test.TestConnector import com.powersync.bucket.WriteCheckpointData import com.powersync.bucket.WriteCheckpointResponse import com.powersync.createPowerSyncDatabaseImpl +import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.configureSyncHttpClient +import com.powersync.test.cleanup +import com.powersync.test.factory +import com.powersync.test.getTempDir import com.powersync.utils.JsonUtil import io.ktor.client.HttpClient import io.ktor.client.engine.mock.toByteArray @@ -29,14 +32,6 @@ import kotlinx.io.files.Path import kotlinx.serialization.json.JsonElement import kotlin.coroutines.resume -expect val factory: PersistentDriverFactory - -expect fun cleanup(path: String) - -expect fun getTempDir(): String - -expect fun isIOS(): Boolean - fun generatePrintLogWriter() = object : LogWriter() { override fun log( diff --git a/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt b/common/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt similarity index 100% rename from core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt rename to common/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index eeb742fb..8df85d51 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -6,7 +6,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinTest plugins { alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.kotlinSerialization) alias(libs.plugins.android.library) alias(libs.plugins.mavenPublishPlugin) alias(libs.plugins.downloadPlugin) @@ -86,13 +85,7 @@ kotlin { tvosMain.orNull?.dependsOn(appleNonWatchOsMain) commonTest.dependencies { - implementation(libs.kotlin.test) - implementation(libs.test.coroutines) - implementation(libs.test.turbine) - implementation(libs.test.kotest.assertions) - implementation(libs.kermit.test) - implementation(libs.ktor.client.mock) - implementation(libs.test.turbine) + implementation(projects.internal.testutils) } // We're putting the native libraries into our JAR, so integration tests for the JVM can run as part of the unit diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt similarity index 92% rename from common/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt rename to core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt index ebc04960..4d09c9dc 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt @@ -10,6 +10,7 @@ import com.powersync.PowerSyncDatabase import com.powersync.db.schema.Column import com.powersync.db.schema.Schema import com.powersync.db.schema.Table +import com.powersync.inMemory import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import kotlinx.coroutines.test.runTest @@ -33,7 +34,7 @@ class InMemoryTest { @Test fun createsSchema() = runTest { - val db = PowerSyncDatabase.Companion.inMemory(schema, this, logger) + val db = PowerSyncDatabase.inMemory(schema, this, logger) try { db.getAll("SELECT * FROM users") { } shouldHaveSize 0 } finally { @@ -44,7 +45,7 @@ class InMemoryTest { @Test fun watch() = runTest { - val db = PowerSyncDatabase.Companion.inMemory(schema, this, logger) + val db = PowerSyncDatabase.inMemory(schema, this, logger) try { turbineScope { val turbine = @@ -73,4 +74,4 @@ class InMemoryTest { ), ) } -} +} \ No newline at end of file diff --git a/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncClientTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncClientTest.kt index 5592dfa6..19107e06 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncClientTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncClientTest.kt @@ -7,7 +7,6 @@ import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import co.touchlab.kermit.TestLogWriter import com.powersync.ExperimentalPowerSyncAPI -import com.powersync.TestConnector import com.powersync.bucket.BucketStorage import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.db.crud.CrudEntry diff --git a/internal/testutils/build.gradle.kts b/internal/testutils/build.gradle.kts new file mode 100644 index 00000000..4590aabd --- /dev/null +++ b/internal/testutils/build.gradle.kts @@ -0,0 +1,54 @@ +import com.powersync.plugins.utils.powersyncTargets + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlinter) + id("com.powersync.plugins.sharedbuild") + alias(libs.plugins.mokkery) + alias(libs.plugins.kotlin.atomicfu) +} + +kotlin { + powersyncTargets() + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain.dependencies { + implementation(projects.common) + + api(libs.kotlin.test) + api(libs.test.coroutines) + api(libs.test.turbine) + api(libs.test.kotest.assertions) + api(libs.kermit.test) + api(libs.ktor.client.mock) + } + + val platformMain by creating { + dependsOn(commonMain.get()) + } + + jvmMain.get().dependsOn(platformMain) + nativeMain.orNull?.dependsOn(platformMain) + } +} + +android { + compileOptions { + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + release { + } + debug { + } + } + + namespace = "com.powersync" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() +} diff --git a/core/src/commonTest/kotlin/com/powersync/PowerSyncTestLogWriter.kt b/internal/testutils/src/commonMain/kotlin/com/powersync/test/PowerSyncTestLogWriter.kt similarity index 77% rename from core/src/commonTest/kotlin/com/powersync/PowerSyncTestLogWriter.kt rename to internal/testutils/src/commonMain/kotlin/com/powersync/test/PowerSyncTestLogWriter.kt index 10e2e602..da3d2ba5 100644 --- a/core/src/commonTest/kotlin/com/powersync/PowerSyncTestLogWriter.kt +++ b/internal/testutils/src/commonMain/kotlin/com/powersync/test/PowerSyncTestLogWriter.kt @@ -1,9 +1,9 @@ -package com.powersync +package com.powersync.test import co.touchlab.kermit.ExperimentalKermitApi import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Severity -import co.touchlab.kermit.TestLogWriter.LogEntry +import co.touchlab.kermit.TestLogWriter import kotlinx.atomicfu.locks.reentrantLock import kotlinx.atomicfu.locks.withLock @@ -16,9 +16,9 @@ class PowerSyncTestLogWriter( private val loggable: Severity, ) : LogWriter() { private val lock = reentrantLock() - private val _logs = mutableListOf() + private val _logs = mutableListOf() - val logs: List + val logs: List get() = lock.withLock { _logs.toList() } override fun isLoggable( @@ -33,7 +33,7 @@ class PowerSyncTestLogWriter( throwable: Throwable?, ) { lock.withLock { - _logs.add(LogEntry(severity, message, tag, throwable)) + _logs.add(TestLogWriter.LogEntry(severity, message, tag, throwable)) } } } diff --git a/core/src/commonTest/kotlin/com/powersync/TestConnector.kt b/internal/testutils/src/commonMain/kotlin/com/powersync/test/TestConnector.kt similarity index 92% rename from core/src/commonTest/kotlin/com/powersync/TestConnector.kt rename to internal/testutils/src/commonMain/kotlin/com/powersync/test/TestConnector.kt index 2f8d81a5..297bdb8e 100644 --- a/core/src/commonTest/kotlin/com/powersync/TestConnector.kt +++ b/internal/testutils/src/commonMain/kotlin/com/powersync/test/TestConnector.kt @@ -1,5 +1,6 @@ -package com.powersync +package com.powersync.test +import com.powersync.PowerSyncDatabase import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.connectors.PowerSyncCredentials diff --git a/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt b/internal/testutils/src/commonMain/kotlin/com/powersync/test/WaitFor.kt similarity index 89% rename from core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt rename to internal/testutils/src/commonMain/kotlin/com/powersync/test/WaitFor.kt index 13f3e449..b8cf083f 100644 --- a/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt +++ b/internal/testutils/src/commonMain/kotlin/com/powersync/test/WaitFor.kt @@ -1,11 +1,11 @@ -package com.powersync.testutils +package com.powersync.test import kotlinx.coroutines.delay import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeSource -internal suspend inline fun waitFor( +suspend inline fun waitFor( timeout: Duration = 500.milliseconds, interval: Duration = 100.milliseconds, test: () -> Unit, diff --git a/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt b/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt new file mode 100644 index 00000000..91cbd794 --- /dev/null +++ b/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt @@ -0,0 +1,18 @@ +package com.powersync.test + +import com.powersync.PersistentDriverFactory + +class TestPlatform { +} + +val factory: PersistentDriverFactory get() = TODO() + +fun cleanup(path: String) {} + +fun getTempDir(): String { + TODO() +} + +fun isIOS(): Boolean { + TODO() +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 4621c775..064dd7e2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,6 +29,7 @@ rootProject.name = "powersync-root" include(":internal:download-core-extension") include(":internal:PowerSyncKotlin") +include(":internal:testutils") include(":common") include(":core") From f16eedf021fe7bc64d071a977059c46ae2de93d4 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 17 Oct 2025 12:02:07 +0200 Subject: [PATCH 31/41] Well, compiling works --- common/build.gradle.kts | 17 --------- .../kotlin/com/powersync/DatabaseTest.kt | 13 +------ .../com/powersync/DatabaseDriverFactory.kt | 20 +++++----- .../com/powersync/PowerSyncDatabaseFactory.kt | 4 +- .../db/driver/InternalConnectionPool.kt | 4 +- .../powersync/bucket/BucketStorageTest.kt | 0 .../powersync/db/ActiveDatabaseGroupTest.kt | 5 ++- .../kotlin}/powersync/db/FunctionTest.kt | 3 +- .../kotlin}/powersync/db/schema/SchemaTest.kt | 6 ++- .../kotlin}/powersync/db/schema/TableTest.kt | 38 +++++++++++++++---- .../kotlin}/powersync/sync/ProgressTest.kt | 3 +- .../powersync/sync/StreamingSyncClientTest.kt | 6 ++- .../sync/StreamingSyncRequestTest.kt | 0 .../kotlin}/powersync/sync/SyncLineTest.kt | 4 +- .../kotlin}/powersync/utils/JsonTest.kt | 4 +- .../kotlin}/powersync/utils/ThrottleTest.kt | 3 +- core/build.gradle.kts | 10 ----- .../DatabaseDriverFactory.android.kt | 11 ++---- .../powersync/testutils/TestUtils.apple.kt | 20 ---------- .../com/powersync/DatabaseDriverFactory.kt | 17 ++------- .../kotlin/com/powersync/PowerSyncDatabase.kt | 2 +- .../powersync/DatabaseDriverFactory.jvm.kt | 11 ++---- .../DatabaseDriverFactory.native.kt} | 11 +++--- .../DatabaseDriverFactory.watchos.kt | 25 ------------ core/src/watchosMain/powersync_static.h | 1 - internal/testutils/build.gradle.kts | 6 +++ .../com/powersync/test/TestPlatform.apple.kt | 6 +++ .../com/powersync/test/TestPlatform.jvm.kt | 6 +++ .../kotlin/com/powersync/test/TestPlatform.kt | 23 +++++------ 29 files changed, 119 insertions(+), 160 deletions(-) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/bucket/BucketStorageTest.kt (100%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/db/ActiveDatabaseGroupTest.kt (93%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/db/FunctionTest.kt (91%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/db/schema/SchemaTest.kt (88%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/db/schema/TableTest.kt (86%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/sync/ProgressTest.kt (85%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/sync/StreamingSyncClientTest.kt (97%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/sync/StreamingSyncRequestTest.kt (100%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/sync/SyncLineTest.kt (96%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/utils/JsonTest.kt (98%) rename {core/src/commonTest/kotlin/com => common/src/commonTest/kotlin}/powersync/utils/ThrottleTest.kt (96%) delete mode 100644 core/src/appleTest/kotlin/com/powersync/testutils/TestUtils.apple.kt rename core/src/{appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt => nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt} (71%) delete mode 100644 core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt delete mode 100644 core/src/watchosMain/powersync_static.h create mode 100644 internal/testutils/src/appleMain/kotlin/com/powersync/test/TestPlatform.apple.kt create mode 100644 internal/testutils/src/jvmMain/kotlin/com/powersync/test/TestPlatform.jvm.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index b1a2c3cd..4812b072 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -171,28 +171,11 @@ kotlin { dependsOn(commonJava) dependencies { api(libs.powersync.sqlite.core.android) - implementation(libs.ktor.client.okhttp) - implementation(libs.androidx.sqlite.bundled) } } jvmMain { dependsOn(commonJava) - - 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. - implementation(projects.staticSqliteDriver) } // Common apple targets where we link the core extension dynamically diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 726eb318..40604b4b 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -11,7 +11,6 @@ import com.powersync.db.schema.Schema import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest import com.powersync.test.getTempDir -import com.powersync.test.isIOS import com.powersync.test.waitFor import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldHaveSize @@ -266,17 +265,7 @@ class DatabaseTest { @Test fun openDBWithDirectory() = databaseTest { - val tempDir = - if (isIOS()) { - null - } else { - getTempDir() - } - - if (tempDir == null) { - // SQLiteR, which is used on iOS, does not support opening dbs from directories - return@databaseTest - } + val tempDir = getTempDir() // On platforms that support it, openDatabase() from our test utils should use a temporary // location. diff --git a/common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 85692afb..6f1dcb79 100644 --- a/common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -1,18 +1,13 @@ package com.powersync import androidx.sqlite.SQLiteConnection -import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig +import androidx.sqlite.SQLiteDriver -public interface PowerSyncPlatform { +public interface InMemoryConnectionFactory { public fun openInMemoryConnection(): SQLiteConnection - - public fun configureHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient } -public interface PersistentDriverFactory { - public val platform: PowerSyncPlatform - +public interface PersistentConnectionFactory: InMemoryConnectionFactory { public fun resolveDefaultDatabasePath(dbFilename: String): String /** @@ -48,6 +43,14 @@ public interface PersistentDriverFactory { } } +public open class DriverBasedInMemoryFactory( + protected val driver: D, +): InMemoryConnectionFactory { + override fun openInMemoryConnection(): SQLiteConnection { + return driver.open(":memory:") + } +} + /** * Resolves a path to the loadable PowerSync core extension library. * @@ -65,4 +68,3 @@ public expect fun resolvePowerSyncLoadableExtensionPath(): String? private const val SQLITE_OPEN_READONLY = 0x01 private const val SQLITE_OPEN_READWRITE = 0x02 private const val SQLITE_OPEN_CREATE = 0x04 - diff --git a/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt index 6fd6cb67..824b6e04 100644 --- a/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt +++ b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt @@ -20,7 +20,7 @@ public const val DEFAULT_DB_FILENAME: String = "powersync.db" @OptIn(DelicateCoroutinesApi::class) @DefaultArgumentInterop.Enabled public fun PowerSyncDatabase( - factory: PersistentDriverFactory, + factory: PersistentConnectionFactory, schema: Schema, dbFilename: String = DEFAULT_DB_FILENAME, scope: CoroutineScope = GlobalScope, @@ -45,7 +45,7 @@ public fun PowerSyncDatabase( @OptIn(ExperimentalPowerSyncAPI::class) internal fun createPowerSyncDatabaseImpl( - factory: PersistentDriverFactory, + factory: PersistentConnectionFactory, schema: Schema, dbFilename: String, scope: CoroutineScope, diff --git a/common/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt b/common/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt index 1663d359..00489429 100644 --- a/common/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt +++ b/common/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt @@ -3,7 +3,7 @@ package com.powersync.db.driver import androidx.sqlite.SQLiteConnection import androidx.sqlite.execSQL import com.powersync.ExperimentalPowerSyncAPI -import com.powersync.PersistentDriverFactory +import com.powersync.PersistentConnectionFactory import com.powersync.utils.JsonUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -14,7 +14,7 @@ import kotlinx.coroutines.sync.withLock @OptIn(ExperimentalPowerSyncAPI::class) internal class InternalConnectionPool( - private val factory: PersistentDriverFactory, + private val factory: PersistentConnectionFactory, private val scope: CoroutineScope, private val dbFilename: String, private val dbDirectory: String?, diff --git a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt b/common/src/commonTest/kotlin/powersync/bucket/BucketStorageTest.kt similarity index 100% rename from core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt rename to common/src/commonTest/kotlin/powersync/bucket/BucketStorageTest.kt diff --git a/core/src/commonTest/kotlin/com/powersync/db/ActiveDatabaseGroupTest.kt b/common/src/commonTest/kotlin/powersync/db/ActiveDatabaseGroupTest.kt similarity index 93% rename from core/src/commonTest/kotlin/com/powersync/db/ActiveDatabaseGroupTest.kt rename to common/src/commonTest/kotlin/powersync/db/ActiveDatabaseGroupTest.kt index 87c84124..0b2a843f 100644 --- a/core/src/commonTest/kotlin/com/powersync/db/ActiveDatabaseGroupTest.kt +++ b/common/src/commonTest/kotlin/powersync/db/ActiveDatabaseGroupTest.kt @@ -1,10 +1,11 @@ -package com.powersync.db +package powersync.db import co.touchlab.kermit.ExperimentalKermitApi import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import co.touchlab.kermit.TestLogWriter +import com.powersync.db.ActiveDatabaseGroup import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -52,7 +53,7 @@ class ActiveDatabaseGroupTest { val another = collection.referenceDatabase(logger, "test") assertNotNull( logWriter.logs.find { - it.message == ActiveDatabaseGroup.multipleInstancesMessage + it.message == ActiveDatabaseGroup.Companion.multipleInstancesMessage }, ) diff --git a/core/src/commonTest/kotlin/com/powersync/db/FunctionTest.kt b/common/src/commonTest/kotlin/powersync/db/FunctionTest.kt similarity index 91% rename from core/src/commonTest/kotlin/com/powersync/db/FunctionTest.kt rename to common/src/commonTest/kotlin/powersync/db/FunctionTest.kt index fc5089a9..7ab5c4f9 100644 --- a/core/src/commonTest/kotlin/com/powersync/db/FunctionTest.kt +++ b/common/src/commonTest/kotlin/powersync/db/FunctionTest.kt @@ -1,5 +1,6 @@ -package com.powersync.db +package powersync.db +import com.powersync.db.runWrapped import io.kotest.assertions.throwables.shouldThrow import kotlinx.coroutines.CancellationException import kotlin.test.Test diff --git a/core/src/commonTest/kotlin/com/powersync/db/schema/SchemaTest.kt b/common/src/commonTest/kotlin/powersync/db/schema/SchemaTest.kt similarity index 88% rename from core/src/commonTest/kotlin/com/powersync/db/schema/SchemaTest.kt rename to common/src/commonTest/kotlin/powersync/db/schema/SchemaTest.kt index 81cce024..6a424fc8 100644 --- a/core/src/commonTest/kotlin/com/powersync/db/schema/SchemaTest.kt +++ b/common/src/commonTest/kotlin/powersync/db/schema/SchemaTest.kt @@ -1,5 +1,9 @@ -package com.powersync.db.schema +package powersync.db.schema +import com.powersync.db.schema.Column +import com.powersync.db.schema.ColumnType +import com.powersync.db.schema.Schema +import com.powersync.db.schema.Table import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/core/src/commonTest/kotlin/com/powersync/db/schema/TableTest.kt b/common/src/commonTest/kotlin/powersync/db/schema/TableTest.kt similarity index 86% rename from core/src/commonTest/kotlin/com/powersync/db/schema/TableTest.kt rename to common/src/commonTest/kotlin/powersync/db/schema/TableTest.kt index 839be340..60e0d4e9 100644 --- a/core/src/commonTest/kotlin/com/powersync/db/schema/TableTest.kt +++ b/common/src/commonTest/kotlin/powersync/db/schema/TableTest.kt @@ -1,5 +1,13 @@ -package com.powersync.db.schema - +package powersync.db.schema + +import com.powersync.db.schema.Column +import com.powersync.db.schema.ColumnType +import com.powersync.db.schema.Index +import com.powersync.db.schema.IndexedColumn +import com.powersync.db.schema.SerializableTable +import com.powersync.db.schema.Table +import com.powersync.db.schema.TrackPreviousValuesOptions +import com.powersync.db.schema.toSerializable import com.powersync.utils.JsonUtil import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe @@ -33,7 +41,7 @@ class TableTest { @Test fun testLocalOnlyTable() { val columns = listOf(Column("content", ColumnType.TEXT)) - val table = Table.localOnly("notes", columns) + val table = Table.Companion.localOnly("notes", columns) assertTrue(table.internalName.startsWith("ps_data_local__")) assertEquals("notes", table.viewName) @@ -42,7 +50,7 @@ class TableTest { @Test fun testInsertOnlyTable() { val columns = listOf(Column("event", ColumnType.TEXT)) - val table = Table.insertOnly("logs", columns) + val table = Table.Companion.insertOnly("logs", columns) assertTrue(table.internalName.startsWith("ps_data__")) assertEquals("logs", table.viewName) @@ -166,7 +174,10 @@ class TableTest { @Test fun testValidationFailsDuplicateIndexColumn() { val columns = listOf(Column("name", ColumnType.TEXT)) - val indexes = listOf(Index("name_index", listOf(IndexedColumn("name"))), Index("name_index", listOf(IndexedColumn("name")))) + val indexes = listOf( + Index("name_index", listOf(IndexedColumn("name"))), + Index("name_index", listOf(IndexedColumn("name"))) + ) val table = Table("users", columns, indexes) val exception = @@ -192,7 +203,7 @@ class TableTest { @Test fun testValidationLocalOnlyWithMetadata() { - val table = Table("foo", listOf(Column.text("bar")), localOnly = true, trackMetadata = true) + val table = Table("foo", listOf(Column.Companion.text("bar")), localOnly = true, trackMetadata = true) val exception = shouldThrow { table.validate() } exception.message shouldBe "Can't track metadata for local-only tables." @@ -200,7 +211,12 @@ class TableTest { @Test fun testValidationLocalOnlyWithIncludeOld() { - val table = Table("foo", listOf(Column.text("bar")), localOnly = true, trackPreviousValues = TrackPreviousValuesOptions()) + val table = Table( + "foo", + listOf(Column.Companion.text("bar")), + localOnly = true, + trackPreviousValues = TrackPreviousValuesOptions() + ) val exception = shouldThrow { table.validate() } exception.message shouldBe "Can't track old values for local-only tables." @@ -219,7 +235,13 @@ class TableTest { it["include_old_only_when_changed"]!!.jsonPrimitive.boolean shouldBe false } - serialize(Table("foo", emptyList(), trackPreviousValues = TrackPreviousValuesOptions(columnFilter = listOf("foo", "bar")))).let { + serialize( + Table( + "foo", + emptyList(), + trackPreviousValues = TrackPreviousValuesOptions(columnFilter = listOf("foo", "bar")) + ) + ).let { it["include_old"]!!.jsonArray.map { e -> e.jsonPrimitive.content } shouldBe listOf("foo", "bar") it["include_old_only_when_changed"]!!.jsonPrimitive.boolean shouldBe false } diff --git a/core/src/commonTest/kotlin/com/powersync/sync/ProgressTest.kt b/common/src/commonTest/kotlin/powersync/sync/ProgressTest.kt similarity index 85% rename from core/src/commonTest/kotlin/com/powersync/sync/ProgressTest.kt rename to common/src/commonTest/kotlin/powersync/sync/ProgressTest.kt index c43e7c51..972182e7 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/ProgressTest.kt +++ b/common/src/commonTest/kotlin/powersync/sync/ProgressTest.kt @@ -1,5 +1,6 @@ -package com.powersync.sync +package powersync.sync +import com.powersync.sync.ProgressInfo import kotlin.test.Test import kotlin.test.assertEquals diff --git a/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncClientTest.kt b/common/src/commonTest/kotlin/powersync/sync/StreamingSyncClientTest.kt similarity index 97% rename from core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncClientTest.kt rename to common/src/commonTest/kotlin/powersync/sync/StreamingSyncClientTest.kt index 19107e06..3da4a5cc 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncClientTest.kt +++ b/common/src/commonTest/kotlin/powersync/sync/StreamingSyncClientTest.kt @@ -1,4 +1,4 @@ -package com.powersync.sync +package powersync.sync import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi @@ -12,7 +12,11 @@ import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.db.crud.CrudEntry import com.powersync.db.crud.UpdateType import com.powersync.db.schema.Schema +import com.powersync.sync.StreamingSyncClient import com.powersync.sync.StreamingSyncClient.Companion.bsonObjects +import com.powersync.sync.SyncClientConfiguration +import com.powersync.sync.SyncOptions +import com.powersync.sync.configureSyncHttpClient import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.mock diff --git a/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncRequestTest.kt b/common/src/commonTest/kotlin/powersync/sync/StreamingSyncRequestTest.kt similarity index 100% rename from core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncRequestTest.kt rename to common/src/commonTest/kotlin/powersync/sync/StreamingSyncRequestTest.kt diff --git a/core/src/commonTest/kotlin/com/powersync/sync/SyncLineTest.kt b/common/src/commonTest/kotlin/powersync/sync/SyncLineTest.kt similarity index 96% rename from core/src/commonTest/kotlin/com/powersync/sync/SyncLineTest.kt rename to common/src/commonTest/kotlin/powersync/sync/SyncLineTest.kt index c5fff921..f69e0000 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncLineTest.kt +++ b/common/src/commonTest/kotlin/powersync/sync/SyncLineTest.kt @@ -1,8 +1,10 @@ -package com.powersync.sync +package powersync.sync import com.powersync.bucket.BucketChecksum import com.powersync.bucket.Checkpoint import com.powersync.bucket.StreamPriority +import com.powersync.sync.LegacySyncImplementation +import com.powersync.sync.SyncLine import com.powersync.utils.JsonUtil import kotlin.test.Test import kotlin.test.assertEquals diff --git a/core/src/commonTest/kotlin/com/powersync/utils/JsonTest.kt b/common/src/commonTest/kotlin/powersync/utils/JsonTest.kt similarity index 98% rename from core/src/commonTest/kotlin/com/powersync/utils/JsonTest.kt rename to common/src/commonTest/kotlin/powersync/utils/JsonTest.kt index 0157012f..327c58dd 100644 --- a/core/src/commonTest/kotlin/com/powersync/utils/JsonTest.kt +++ b/common/src/commonTest/kotlin/powersync/utils/JsonTest.kt @@ -1,5 +1,7 @@ -package com.powersync.utils +package powersync.utils +import com.powersync.utils.JsonParam +import com.powersync.utils.toJsonObject import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject diff --git a/core/src/commonTest/kotlin/com/powersync/utils/ThrottleTest.kt b/common/src/commonTest/kotlin/powersync/utils/ThrottleTest.kt similarity index 96% rename from core/src/commonTest/kotlin/com/powersync/utils/ThrottleTest.kt rename to common/src/commonTest/kotlin/powersync/utils/ThrottleTest.kt index 8aa87b7a..45c0b114 100644 --- a/core/src/commonTest/kotlin/com/powersync/utils/ThrottleTest.kt +++ b/common/src/commonTest/kotlin/powersync/utils/ThrottleTest.kt @@ -1,5 +1,6 @@ -package com.powersync.utils +package powersync.utils +import com.powersync.utils.throttle import io.kotest.matchers.shouldBe import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 8df85d51..5f4e993c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -13,7 +13,6 @@ plugins { id("com.powersync.plugins.sonatype") id("com.powersync.plugins.sharedbuild") alias(libs.plugins.mokkery) - alias(libs.plugins.kotlin.atomicfu) id("dokka-convention") } @@ -75,15 +74,6 @@ kotlin { 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(projects.internal.testutils) } diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 16542735..79b19311 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -8,12 +8,7 @@ import kotlin.Throws @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public actual class DatabaseDriverFactory( private val context: Context, -): PersistentDriverFactory { - private val driver = BundledSQLiteDriver().also { it.addPowerSyncExtension() } - - actual override val platform: PowerSyncPlatform - get() = BuiltinPlatform - +): PersistentConnectionFactory, DriverBasedInMemoryFactory(newDriver()) { actual override fun resolveDefaultDatabasePath(dbFilename: String): String = context.getDatabasePath(dbFilename).path actual override fun openConnection( @@ -22,8 +17,10 @@ public actual class DatabaseDriverFactory( ): SQLiteConnection = driver.open(path, openFlags) } +private fun newDriver() = BundledSQLiteDriver().also { it.addPowerSyncExtension() } + public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension("libpowersync.so", "sqlite3_powersync_init") } -internal actual fun openInMemoryConnection(): SQLiteConnection = BundledSQLiteDriver().also { it.addPowerSyncExtension() }.open(":memory:") +internal actual val inMemoryDriver: InMemoryConnectionFactory = DriverBasedInMemoryFactory(newDriver()) diff --git a/core/src/appleTest/kotlin/com/powersync/testutils/TestUtils.apple.kt b/core/src/appleTest/kotlin/com/powersync/testutils/TestUtils.apple.kt deleted file mode 100644 index 396be545..00000000 --- a/core/src/appleTest/kotlin/com/powersync/testutils/TestUtils.apple.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.powersync.testutils - -import com.powersync.DatabaseDriverFactory -import kotlinx.io.files.Path -import kotlinx.io.files.SystemFileSystem -import platform.Foundation.NSTemporaryDirectory - -actual val factory: DatabaseDriverFactory - get() = DatabaseDriverFactory() - -actual fun cleanup(path: String) { - val resolved = Path(path) - if (SystemFileSystem.exists(resolved)) { - SystemFileSystem.delete(resolved) - } -} - -actual fun getTempDir(): String = NSTemporaryDirectory() - -actual fun isIOS(): Boolean = true diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index acfdd99e..71196cfc 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -1,23 +1,12 @@ package com.powersync import androidx.sqlite.SQLiteConnection -import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -public expect class DatabaseDriverFactory: PersistentDriverFactory { - override val platform: PowerSyncPlatform - +public expect class DatabaseDriverFactory: PersistentConnectionFactory { + override fun openInMemoryConnection(): SQLiteConnection override fun resolveDefaultDatabasePath(dbFilename: String): String override fun openConnection(path: String, openFlags: Int): SQLiteConnection } -internal expect fun openInMemoryConnection(): SQLiteConnection - -internal object BuiltinPlatform: PowerSyncPlatform { - override fun openInMemoryConnection(): SQLiteConnection { - return com.powersync.openInMemoryConnection() - } - - override fun configureHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(block) -} +internal expect val inMemoryDriver: InMemoryConnectionFactory diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index a750a771..34f19203 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -22,7 +22,7 @@ public fun PowerSyncDatabase.PowerSyncOpenFactory.inMemory( val collection = ActiveDatabaseGroup.GroupsCollection().referenceDatabase(logger, "test") return openedWithGroup( - SingleConnectionPool(openInMemoryConnection()), + SingleConnectionPool(inMemoryDriver.openInMemoryConnection()), scope, schema, logger, diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 56b7d707..765960fb 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -4,12 +4,7 @@ import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") -public actual class DatabaseDriverFactory: PersistentDriverFactory { - actual override val platform: PowerSyncPlatform - get() = BuiltinPlatform - - private val driver = BundledSQLiteDriver().also { it.addPowerSyncExtension() } - +public actual class DatabaseDriverFactory: PersistentConnectionFactory, DriverBasedInMemoryFactory(newDriver()) { actual override fun resolveDefaultDatabasePath(dbFilename: String): String = dbFilename actual override fun openConnection( @@ -18,9 +13,11 @@ public actual class DatabaseDriverFactory: PersistentDriverFactory { ): SQLiteConnection = driver.open(path, openFlags) } +private fun newDriver() = BundledSQLiteDriver().also { it.addPowerSyncExtension() } + @OptIn(ExperimentalPowerSyncAPI::class) public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension(resolvePowerSyncLoadableExtensionPath()!!, "sqlite3_powersync_init") } -internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02) +internal actual val inMemoryDriver: InMemoryConnectionFactory = DriverBasedInMemoryFactory(newDriver()) diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt similarity index 71% rename from core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt rename to core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt index 89d76c6d..86d6c093 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt @@ -4,10 +4,7 @@ import androidx.sqlite.SQLiteConnection import com.powersync.sqlite.Database @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) -public actual class DatabaseDriverFactory: PersistentDriverFactory { - actual override val platform: PowerSyncPlatform - get() = BuiltinPlatform - +public actual class DatabaseDriverFactory: PersistentConnectionFactory { actual override fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) @OptIn(ExperimentalPowerSyncAPI::class) @@ -24,6 +21,10 @@ public actual class DatabaseDriverFactory: PersistentDriverFactory { } return db } + + actual override fun openInMemoryConnection(): SQLiteConnection { + return openConnection(":memory:", 0x02) + } } -internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02) +internal actual val inMemoryDriver: InMemoryConnectionFactory = DatabaseDriverFactory() diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt deleted file mode 100644 index 2dcc7e1f..00000000 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.powersync - -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: PersistentDriverFactory { - actual override val platform: PowerSyncPlatform - get() = BuiltinPlatform - - actual override fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) - - @OptIn(ExperimentalPowerSyncAPI::class) - actual override fun openConnection( - path: String, - openFlags: Int, - ): SQLiteConnection { - resolvePowerSyncLoadableExtensionPath() - - return Database.open(path, openFlags) - } -} - -internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02) diff --git a/core/src/watchosMain/powersync_static.h b/core/src/watchosMain/powersync_static.h deleted file mode 100644 index 9a1d3560..00000000 --- a/core/src/watchosMain/powersync_static.h +++ /dev/null @@ -1 +0,0 @@ -int powersync_init_static(); diff --git a/internal/testutils/build.gradle.kts b/internal/testutils/build.gradle.kts index 4590aabd..b21974c7 100644 --- a/internal/testutils/build.gradle.kts +++ b/internal/testutils/build.gradle.kts @@ -27,6 +27,12 @@ kotlin { val platformMain by creating { dependsOn(commonMain.get()) + + dependencies { + // :core links SQLite, which is what we want for tests even in the :common project where the public API + // does not require linking SQLite. + api(projects.core) + } } jvmMain.get().dependsOn(platformMain) diff --git a/internal/testutils/src/appleMain/kotlin/com/powersync/test/TestPlatform.apple.kt b/internal/testutils/src/appleMain/kotlin/com/powersync/test/TestPlatform.apple.kt new file mode 100644 index 00000000..698e4890 --- /dev/null +++ b/internal/testutils/src/appleMain/kotlin/com/powersync/test/TestPlatform.apple.kt @@ -0,0 +1,6 @@ +package com.powersync.test + +import com.powersync.DatabaseDriverFactory +import com.powersync.PersistentConnectionFactory + +actual val factory: PersistentConnectionFactory = DatabaseDriverFactory() diff --git a/internal/testutils/src/jvmMain/kotlin/com/powersync/test/TestPlatform.jvm.kt b/internal/testutils/src/jvmMain/kotlin/com/powersync/test/TestPlatform.jvm.kt new file mode 100644 index 00000000..698e4890 --- /dev/null +++ b/internal/testutils/src/jvmMain/kotlin/com/powersync/test/TestPlatform.jvm.kt @@ -0,0 +1,6 @@ +package com.powersync.test + +import com.powersync.DatabaseDriverFactory +import com.powersync.PersistentConnectionFactory + +actual val factory: PersistentConnectionFactory = DatabaseDriverFactory() diff --git a/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt b/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt index 91cbd794..49789aad 100644 --- a/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt +++ b/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt @@ -1,18 +1,19 @@ package com.powersync.test -import com.powersync.PersistentDriverFactory +import com.powersync.PersistentConnectionFactory +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.files.SystemTemporaryDirectory -class TestPlatform { -} - -val factory: PersistentDriverFactory get() = TODO() - -fun cleanup(path: String) {} +expect val factory: PersistentConnectionFactory -fun getTempDir(): String { - TODO() +fun cleanup(path: String) { + val resolved = Path(path) + if (SystemFileSystem.exists(resolved)) { + SystemFileSystem.delete(resolved) + } } -fun isIOS(): Boolean { - TODO() +fun getTempDir(): String { + return SystemTemporaryDirectory.name } From eaaeaed50b6be66599c7635da7b7deb7badc1293 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 17 Oct 2025 13:32:07 +0200 Subject: [PATCH 32/41] Fixing some tests --- README.md | 9 +++++++- common/build.gradle.kts | 23 ++++++++++++++++++- .../kotlin/powersync/db/FunctionTest.kt | 1 + .../powersync/sync/StreamingSyncClientTest.kt | 1 + .../powersync/DatabaseDriverFactory.jvm.kt | 17 ++++++++++---- .../kotlin/com/powersync/test/TestPlatform.kt | 2 +- 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ad0d92ed..f6c3012f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,14 @@ and API documentation [here](https://powersync-ja.github.io/powersync-kotlin/). - [core](./core/) - - This is the Kotlin Multiplatform SDK implementation. + - This is the Kotlin Multiplatform SDK implementation, built by depending on `common` + and linking SQLite. + +- [common](./common/) + + - This is the Kotlin Multiplatform SDK implementation without a dependency on a fixed + SQLite bundle. This allows the SDK to be used with custom SQLite installations (like + e.g. SQLCipher). - [integrations](./integrations/) - [room](./integrations/room/README.md): Allows using the [Room database library](https://developer.android.com/jetpack/androidx/releases/room) with PowerSync, making it easier to run typed queries on the database. diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 4812b072..d2caf544 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -3,6 +3,7 @@ import de.undercouch.gradle.tasks.download.Download import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.internal.os.OperatingSystem import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest import org.jetbrains.kotlin.gradle.tasks.KotlinTest import org.jetbrains.kotlin.konan.target.Family import java.nio.file.Path @@ -189,11 +190,14 @@ kotlin { commonTest.dependencies { implementation(projects.internal.testutils) + implementation(libs.kotlin.test) } // We're putting the native libraries into our JAR, so integration tests for the JVM can run as part of the unit // tests. - jvmTest.get().dependsOn(commonIntegrationTest) + jvmTest { + dependsOn(commonIntegrationTest) + } // We have special setup in this build configuration to make these tests link the PowerSync extension, so they // can run integration tests along with the executable for unit testing. @@ -239,6 +243,23 @@ tasks.named(kotlin.jvm().compilations["main"].processResources from(downloadPowersyncDesktopBinaries) } +// We want to build with recent JDKs, but need to make sure we support Java 8. https://jakewharton.com/build-on-latest-java-test-through-lowest-java/ +val testWithJava8 by tasks.registering(KotlinJvmTest::class) { + javaLauncher = + javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(8) + } + + description = "Run tests with Java 8" + group = LifecycleBasePlugin.VERIFICATION_GROUP + + // Copy inputs from the normal test task + val testTask = tasks.getByName("jvmTest") as KotlinJvmTest + classpath = testTask.classpath + testClassesDirs = testTask.testClassesDirs +} +tasks.named("check").configure { dependsOn(testWithJava8) } + tasks.withType { testLogging { events("PASSED", "FAILED", "SKIPPED") diff --git a/common/src/commonTest/kotlin/powersync/db/FunctionTest.kt b/common/src/commonTest/kotlin/powersync/db/FunctionTest.kt index 7ab5c4f9..c5210cd5 100644 --- a/common/src/commonTest/kotlin/powersync/db/FunctionTest.kt +++ b/common/src/commonTest/kotlin/powersync/db/FunctionTest.kt @@ -1,5 +1,6 @@ package powersync.db +import com.powersync.PowerSyncException import com.powersync.db.runWrapped import io.kotest.assertions.throwables.shouldThrow import kotlinx.coroutines.CancellationException diff --git a/common/src/commonTest/kotlin/powersync/sync/StreamingSyncClientTest.kt b/common/src/commonTest/kotlin/powersync/sync/StreamingSyncClientTest.kt index 3da4a5cc..cc239a13 100644 --- a/common/src/commonTest/kotlin/powersync/sync/StreamingSyncClientTest.kt +++ b/common/src/commonTest/kotlin/powersync/sync/StreamingSyncClientTest.kt @@ -17,6 +17,7 @@ import com.powersync.sync.StreamingSyncClient.Companion.bsonObjects import com.powersync.sync.SyncClientConfiguration import com.powersync.sync.SyncOptions import com.powersync.sync.configureSyncHttpClient +import com.powersync.test.TestConnector import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.mock diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 765960fb..a346c561 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -11,13 +11,22 @@ public actual class DatabaseDriverFactory: PersistentConnectionFactory, DriverBa path: String, openFlags: Int, ): SQLiteConnection = driver.open(path, openFlags) -} -private fun newDriver() = BundledSQLiteDriver().also { it.addPowerSyncExtension() } + internal companion object { + fun newDriver(): BundledSQLiteDriver { + return BundledSQLiteDriver().also { addPowerSyncExtension(it) } + } + + @OptIn(ExperimentalPowerSyncAPI::class) + fun addPowerSyncExtension(driver: BundledSQLiteDriver) { + driver.addExtension(resolvePowerSyncLoadableExtensionPath()!!, "sqlite3_powersync_init") + } + } +} @OptIn(ExperimentalPowerSyncAPI::class) public fun BundledSQLiteDriver.addPowerSyncExtension() { - addExtension(resolvePowerSyncLoadableExtensionPath()!!, "sqlite3_powersync_init") + DatabaseDriverFactory.addPowerSyncExtension(this) } -internal actual val inMemoryDriver: InMemoryConnectionFactory = DriverBasedInMemoryFactory(newDriver()) +internal actual val inMemoryDriver: InMemoryConnectionFactory = DriverBasedInMemoryFactory(DatabaseDriverFactory.newDriver()) diff --git a/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt b/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt index 49789aad..9e4b6830 100644 --- a/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt +++ b/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt @@ -15,5 +15,5 @@ fun cleanup(path: String) { } fun getTempDir(): String { - return SystemTemporaryDirectory.name + return SystemTemporaryDirectory.toString() } From 13dd40ef4390affdaf64d947c7213f3587636c7b Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 17 Oct 2025 13:47:37 +0200 Subject: [PATCH 33/41] Update readmes --- common/README.md | 25 +++++++++++++++++++ ...ndroid.kt => ConnectionFactory.android.kt} | 0 .../com/powersync/sync/SyncIntegrationTest.kt | 7 +++--- ...eDriverFactory.kt => ConnectionFactory.kt} | 0 .../connectors/PowerSyncBackendConnector.kt | 5 ++++ ...actory.jvm.kt => ConnectionFactory.jvm.kt} | 0 ...atchos.kt => ConnectionFactory.watchos.kt} | 0 core/README.md | 23 +++-------------- core/build.gradle.kts | 1 + .../powersync/DatabaseDriverFactory.jvm.kt | 17 +++---------- .../com/powersync/testutils/TestUtils.jvm.kt | 15 ----------- 11 files changed, 43 insertions(+), 50 deletions(-) create mode 100644 common/README.md rename common/src/androidMain/kotlin/com/powersync/{DatabaseDriverFactory.android.kt => ConnectionFactory.android.kt} (100%) rename common/src/commonMain/kotlin/com/powersync/{DatabaseDriverFactory.kt => ConnectionFactory.kt} (100%) rename common/src/jvmMain/kotlin/com/powersync/{DatabaseDriverFactory.jvm.kt => ConnectionFactory.jvm.kt} (100%) rename common/src/watchosMain/kotlin/com/powersync/{DatabaseDriverFactory.watchos.kt => ConnectionFactory.watchos.kt} (100%) delete mode 100644 core/src/jvmTest/kotlin/com/powersync/testutils/TestUtils.jvm.kt diff --git a/common/README.md b/common/README.md new file mode 100644 index 00000000..c0b7a1d7 --- /dev/null +++ b/common/README.md @@ -0,0 +1,25 @@ +# PowerSync common + +This module contains core definitions for the PowerSync SDK, without linking or bundling a SQLite dependency. + +This allows the module to be used as a building block for PowerSync SDKs with and without encryption support. + +Users should typically depend on `:core` instead. + +## Structure + +This is a Kotlin Multiplatform project targeting Android, iOS platforms, with the following +structure: + +- `commonMain` - Shared code for all targets, which includes the `PowerSyncBackendConnector` + interface and `PowerSyncBuilder` for building a `PowerSync` instance. It also defines + the `DatabaseDriverFactory` class to be implemented in each platform. +- `commonJava` - Shared logic for Android and Java targets. +- `androidMain` - Android-specific code for loading the core extension. +- `jvmMain` - Java-specific code for loading the core extension. +- `nativeMain` - A SQLite driver implemented with cinterop calls to sqlite3. + +## Attachment Helpers + +This module contains attachment helpers under the `com.powersync.attachments` package. See +the [Attachment Helpers README](../common/src/commonMain/kotlin/com/powersync/attachments/README.md) diff --git a/common/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/common/src/androidMain/kotlin/com/powersync/ConnectionFactory.android.kt similarity index 100% rename from common/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt rename to common/src/androidMain/kotlin/com/powersync/ConnectionFactory.android.kt diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index cfe2e57a..73822cac 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -15,6 +15,7 @@ import com.powersync.bucket.WriteCheckpointData import com.powersync.bucket.WriteCheckpointResponse import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.connectors.PowerSyncCredentials +import com.powersync.connectors.readCachedCredentials import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.PendingStatement import com.powersync.db.schema.PendingStatementParameter @@ -123,7 +124,7 @@ abstract class BaseSyncIntegrationTest( turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) turbine.waitFor { it.connected } - connector.cachedCredentials shouldNotBe null + connector.readCachedCredentials() shouldNotBe null database.disconnect() turbine.waitFor { !it.connected } @@ -134,7 +135,7 @@ abstract class BaseSyncIntegrationTest( waitFor { syncLines.isClosedForSend shouldBe true } // And called invalidateCredentials on the connector - connector.cachedCredentials shouldBe null + connector.readCachedCredentials() shouldBe null } @Test @@ -674,7 +675,7 @@ abstract class BaseSyncIntegrationTest( // Should invalidate credentials when token expires syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 0)) turbine.waitFor { !it.connected } - connector.cachedCredentials shouldBe null + connector.readCachedCredentials() shouldBe null turbine.cancelAndIgnoreRemainingEvents() } diff --git a/common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/common/src/commonMain/kotlin/com/powersync/ConnectionFactory.kt similarity index 100% rename from common/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt rename to common/src/commonMain/kotlin/com/powersync/ConnectionFactory.kt diff --git a/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt b/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt index 17f23302..1b4d74c0 100644 --- a/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt +++ b/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt @@ -128,3 +128,8 @@ public abstract class PowerSyncBackendConnector { @Throws(PowerSyncException::class, CancellationException::class) public abstract suspend fun uploadData(database: PowerSyncDatabase) } + +// Not using this indirection causes linker errors in tests: https://youtrack.jetbrains.com/issue/CMP-3318 +internal fun PowerSyncBackendConnector.readCachedCredentials(): PowerSyncCredentials? { + return this.cachedCredentials +} diff --git a/common/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/common/src/jvmMain/kotlin/com/powersync/ConnectionFactory.jvm.kt similarity index 100% rename from common/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt rename to common/src/jvmMain/kotlin/com/powersync/ConnectionFactory.jvm.kt diff --git a/common/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/common/src/watchosMain/kotlin/com/powersync/ConnectionFactory.watchos.kt similarity index 100% rename from common/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt rename to common/src/watchosMain/kotlin/com/powersync/ConnectionFactory.watchos.kt diff --git a/core/README.md b/core/README.md index ee09bf8a..c5bba791 100644 --- a/core/README.md +++ b/core/README.md @@ -11,27 +11,12 @@ structure: - `commonMain` - Shared code for all targets, which includes the `PowerSyncBackendConnector` interface and `PowerSyncBuilder` for building a `PowerSync` instance. It also defines the `DatabaseDriverFactory` class to be implemented in each platform. -- `commonJava` - Common Java code including a Java SQLite driver using - the [Xerial JDBC Driver](https://github.com/xerial/sqlite-jdbc). This is used by both the Android - and JVM drivers. - `androidMain` - Android specific code, which includes an implementation of - `DatabaseDriverFactory`. -- `jvmMain` - JVM specific code which includes an implementation of `DatabaseDriverFactory`. -- `iosMain` - iOS specific code, which includes am implementation of `DatabaseDriverFactory` class - that creates an instance of `app.cash.sqldelight.driver.native.NativeSqliteDriver` and also sets - up native SQLite bindings for iOS. - -## Note on SQLDelight - -The PowerSync core module, internally makes use -of [SQLDelight](https://sqldelight.github.io/sqldelight/latest/) for it database API and typesafe -database -query generation. - -The PowerSync core module does not currently support integrating with SQLDelight from client -applications. + `PersistentConnectionFactory`. +- `jvmMain` - JVM specific code which includes an implementation of `PersistentConnectionFactory`. +- `nativeMain` - iOS specific code, which includes am implementation of `PersistentConnectionFactory`. ## Attachment Helpers This module contains attachment helpers under the `com.powersync.attachments` package. See -the [Attachment Helpers README](./src/commonMain/kotlin/com/powersync/attachments/README.md) +the [Attachment Helpers README](../common/src/commonMain/kotlin/com/powersync/attachments/README.md) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 5f4e993c..4eb75e6f 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -76,6 +76,7 @@ kotlin { commonTest.dependencies { implementation(projects.internal.testutils) + implementation(libs.kotlin.test) } // We're putting the native libraries into our JAR, so integration tests for the JVM can run as part of the unit diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index a346c561..8999e12b 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -11,22 +11,13 @@ public actual class DatabaseDriverFactory: PersistentConnectionFactory, DriverBa path: String, openFlags: Int, ): SQLiteConnection = driver.open(path, openFlags) - - internal companion object { - fun newDriver(): BundledSQLiteDriver { - return BundledSQLiteDriver().also { addPowerSyncExtension(it) } - } - - @OptIn(ExperimentalPowerSyncAPI::class) - fun addPowerSyncExtension(driver: BundledSQLiteDriver) { - driver.addExtension(resolvePowerSyncLoadableExtensionPath()!!, "sqlite3_powersync_init") - } - } } +internal fun newDriver() = BundledSQLiteDriver().also { it.addPowerSyncExtension() } + @OptIn(ExperimentalPowerSyncAPI::class) public fun BundledSQLiteDriver.addPowerSyncExtension() { - DatabaseDriverFactory.addPowerSyncExtension(this) + addExtension(resolvePowerSyncLoadableExtensionPath()!!, "sqlite3_powersync_init") } -internal actual val inMemoryDriver: InMemoryConnectionFactory = DriverBasedInMemoryFactory(DatabaseDriverFactory.newDriver()) +internal actual val inMemoryDriver: InMemoryConnectionFactory = DriverBasedInMemoryFactory(newDriver()) diff --git a/core/src/jvmTest/kotlin/com/powersync/testutils/TestUtils.jvm.kt b/core/src/jvmTest/kotlin/com/powersync/testutils/TestUtils.jvm.kt deleted file mode 100644 index 9296387a..00000000 --- a/core/src/jvmTest/kotlin/com/powersync/testutils/TestUtils.jvm.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.powersync.testutils - -import com.powersync.DatabaseDriverFactory -import java.io.File - -actual val factory: DatabaseDriverFactory - get() = DatabaseDriverFactory() - -actual fun cleanup(path: String) { - File(path).delete() -} - -actual fun getTempDir(): String = System.getProperty("java.io.tmpdir") - -actual fun isIOS(): Boolean = false From fbaa0da58e6b7d331a09c3d5b031de2f5908013a Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 20 Oct 2025 09:15:42 +0200 Subject: [PATCH 34/41] Reformat --- .../kotlin/com/powersync/AttachmentsTest.kt | 2 +- .../kotlin/com/powersync/DatabaseTest.kt | 4 +-- .../com/powersync/sync/SyncIntegrationTest.kt | 2 +- .../com/powersync/testutils/TestUtils.kt | 4 +-- common/src/commonMain/kotlin/BuildConfig.kt | 2 +- .../kotlin/com/powersync/ConnectionFactory.kt | 10 +++---- .../connectors/PowerSyncBackendConnector.kt | 4 +-- .../db/driver/SingleConnectionPool.kt | 7 ++--- .../kotlin/powersync/db/schema/TableTest.kt | 26 ++++++++++--------- common/src/jvmMain/kotlin/BuildConfig.kt | 2 +- .../kotlin/com/powersync/sqlite/Database.kt | 23 ++++++++-------- core/build.gradle.kts | 13 ---------- .../DatabaseDriverFactory.android.kt | 3 ++- .../kotlin/com/powersync/db/InMemoryTest.kt | 2 +- .../com/powersync/DatabaseDriverFactory.kt | 9 +++++-- .../powersync/DatabaseDriverFactory.jvm.kt | 4 ++- .../powersync/DatabaseDriverFactory.native.kt | 6 ++--- .../integrations/sqldelight/SqlDelightTest.kt | 3 +-- .../kotlin/com/powersync/test/TestPlatform.kt | 4 +-- 19 files changed, 60 insertions(+), 70 deletions(-) diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt index ae042074..47715fa6 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/AttachmentsTest.kt @@ -12,11 +12,11 @@ import com.powersync.attachments.createAttachmentsTable import com.powersync.db.getString import com.powersync.db.schema.Schema import com.powersync.db.schema.Table +import com.powersync.test.getTempDir import com.powersync.testutils.ActiveDatabaseTest import com.powersync.testutils.MockedRemoteStorage import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest -import com.powersync.test.getTempDir import dev.mokkery.answering.throws import dev.mokkery.everySuspend import dev.mokkery.matcher.ArgMatchersScope diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 40604b4b..538c4f4b 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -8,10 +8,10 @@ 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 import com.powersync.test.getTempDir import com.powersync.test.waitFor +import com.powersync.testutils.UserRow +import com.powersync.testutils.databaseTest import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index 73822cac..2993a10c 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -5,7 +5,6 @@ import co.touchlab.kermit.ExperimentalKermitApi import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase import com.powersync.PowerSyncException -import com.powersync.test.TestConnector import com.powersync.bucket.BucketChecksum import com.powersync.bucket.Checkpoint import com.powersync.bucket.OpType @@ -21,6 +20,7 @@ import com.powersync.db.schema.PendingStatement import com.powersync.db.schema.PendingStatementParameter import com.powersync.db.schema.RawTable import com.powersync.db.schema.Schema +import com.powersync.test.TestConnector import com.powersync.test.waitFor import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest diff --git a/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index fd218ab3..d71edc19 100644 --- a/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/common/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -8,8 +8,6 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import com.powersync.ExperimentalPowerSyncAPI -import com.powersync.test.PowerSyncTestLogWriter -import com.powersync.test.TestConnector import com.powersync.bucket.WriteCheckpointData import com.powersync.bucket.WriteCheckpointResponse import com.powersync.createPowerSyncDatabaseImpl @@ -17,6 +15,8 @@ import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.configureSyncHttpClient +import com.powersync.test.PowerSyncTestLogWriter +import com.powersync.test.TestConnector import com.powersync.test.cleanup import com.powersync.test.factory import com.powersync.test.getTempDir diff --git a/common/src/commonMain/kotlin/BuildConfig.kt b/common/src/commonMain/kotlin/BuildConfig.kt index 4cec9226..0b9cc8d3 100644 --- a/common/src/commonMain/kotlin/BuildConfig.kt +++ b/common/src/commonMain/kotlin/BuildConfig.kt @@ -1,5 +1,5 @@ @Suppress - ("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal expect object BuildConfig { val isDebug: Boolean } diff --git a/common/src/commonMain/kotlin/com/powersync/ConnectionFactory.kt b/common/src/commonMain/kotlin/com/powersync/ConnectionFactory.kt index 6f1dcb79..1a8119dc 100644 --- a/common/src/commonMain/kotlin/com/powersync/ConnectionFactory.kt +++ b/common/src/commonMain/kotlin/com/powersync/ConnectionFactory.kt @@ -7,7 +7,7 @@ public interface InMemoryConnectionFactory { public fun openInMemoryConnection(): SQLiteConnection } -public interface PersistentConnectionFactory: InMemoryConnectionFactory { +public interface PersistentConnectionFactory : InMemoryConnectionFactory { public fun resolveDefaultDatabasePath(dbFilename: String): String /** @@ -43,12 +43,10 @@ public interface PersistentConnectionFactory: InMemoryConnectionFactory { } } -public open class DriverBasedInMemoryFactory( +public open class DriverBasedInMemoryFactory( protected val driver: D, -): InMemoryConnectionFactory { - override fun openInMemoryConnection(): SQLiteConnection { - return driver.open(":memory:") - } +) : InMemoryConnectionFactory { + override fun openInMemoryConnection(): SQLiteConnection = driver.open(":memory:") } /** diff --git a/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt b/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt index 1b4d74c0..49761ea1 100644 --- a/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt +++ b/common/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt @@ -130,6 +130,4 @@ public abstract class PowerSyncBackendConnector { } // Not using this indirection causes linker errors in tests: https://youtrack.jetbrains.com/issue/CMP-3318 -internal fun PowerSyncBackendConnector.readCachedCredentials(): PowerSyncCredentials? { - return this.cachedCredentials -} +internal fun PowerSyncBackendConnector.readCachedCredentials(): PowerSyncCredentials? = this.cachedCredentials diff --git a/common/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt b/common/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt index 4a9c2b36..f23a66d4 100644 --- a/common/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt +++ b/common/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt @@ -42,9 +42,10 @@ public class SingleConnectionPool( override suspend fun withAllConnections( action: suspend (writer: SQLiteConnectionLease, readers: List) -> R, - ): Unit = write { writer -> - action(writer, emptyList()) - } + ): Unit = + write { writer -> + action(writer, emptyList()) + } override val updates: SharedFlow> get() = tableUpdatesFlow diff --git a/common/src/commonTest/kotlin/powersync/db/schema/TableTest.kt b/common/src/commonTest/kotlin/powersync/db/schema/TableTest.kt index 60e0d4e9..6fa51754 100644 --- a/common/src/commonTest/kotlin/powersync/db/schema/TableTest.kt +++ b/common/src/commonTest/kotlin/powersync/db/schema/TableTest.kt @@ -174,10 +174,11 @@ class TableTest { @Test fun testValidationFailsDuplicateIndexColumn() { val columns = listOf(Column("name", ColumnType.TEXT)) - val indexes = listOf( - Index("name_index", listOf(IndexedColumn("name"))), - Index("name_index", listOf(IndexedColumn("name"))) - ) + val indexes = + listOf( + Index("name_index", listOf(IndexedColumn("name"))), + Index("name_index", listOf(IndexedColumn("name"))), + ) val table = Table("users", columns, indexes) val exception = @@ -211,12 +212,13 @@ class TableTest { @Test fun testValidationLocalOnlyWithIncludeOld() { - val table = Table( - "foo", - listOf(Column.Companion.text("bar")), - localOnly = true, - trackPreviousValues = TrackPreviousValuesOptions() - ) + val table = + Table( + "foo", + listOf(Column.Companion.text("bar")), + localOnly = true, + trackPreviousValues = TrackPreviousValuesOptions(), + ) val exception = shouldThrow { table.validate() } exception.message shouldBe "Can't track old values for local-only tables." @@ -239,8 +241,8 @@ class TableTest { Table( "foo", emptyList(), - trackPreviousValues = TrackPreviousValuesOptions(columnFilter = listOf("foo", "bar")) - ) + trackPreviousValues = TrackPreviousValuesOptions(columnFilter = listOf("foo", "bar")), + ), ).let { it["include_old"]!!.jsonArray.map { e -> e.jsonPrimitive.content } shouldBe listOf("foo", "bar") it["include_old_only_when_changed"]!!.jsonPrimitive.boolean shouldBe false diff --git a/common/src/jvmMain/kotlin/BuildConfig.kt b/common/src/jvmMain/kotlin/BuildConfig.kt index 6350a13c..86924a14 100644 --- a/common/src/jvmMain/kotlin/BuildConfig.kt +++ b/common/src/jvmMain/kotlin/BuildConfig.kt @@ -8,5 +8,5 @@ internal actual object BuildConfig { */ actual val isDebug: Boolean = System.getProperty("com.powersync.debug") == "true" || - System.getenv("POWERSYNC_JVM_DEBUG") == "true" + System.getenv("POWERSYNC_JVM_DEBUG") == "true" } diff --git a/common/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt b/common/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt index 27b8c9f4..d489ae9e 100644 --- a/common/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt +++ b/common/src/nativeMain/kotlin/com/powersync/sqlite/Database.kt @@ -55,19 +55,20 @@ public class Database( public fun loadExtension( filename: String, entrypoint: String, - ): 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) - } + ): 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) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 4eb75e6f..1193e3bf 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -94,19 +94,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - buildFeatures { - buildConfig = true - } - - buildTypes { - release { - buildConfigField("boolean", "DEBUG", "false") - } - debug { - buildConfigField("boolean", "DEBUG", "true") - } - } - namespace = "com.powersync" compileSdk = libs.versions.android.compileSdk diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 79b19311..dc98b663 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -8,7 +8,8 @@ import kotlin.Throws @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public actual class DatabaseDriverFactory( private val context: Context, -): PersistentConnectionFactory, DriverBasedInMemoryFactory(newDriver()) { +) : DriverBasedInMemoryFactory(newDriver()), + PersistentConnectionFactory { actual override fun resolveDefaultDatabasePath(dbFilename: String): String = context.getDatabasePath(dbFilename).path actual override fun openConnection( diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt index 4d09c9dc..179e49e2 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt @@ -74,4 +74,4 @@ class InMemoryTest { ), ) } -} \ No newline at end of file +} diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 71196cfc..eef592be 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -3,10 +3,15 @@ package com.powersync import androidx.sqlite.SQLiteConnection @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -public expect class DatabaseDriverFactory: PersistentConnectionFactory { +public expect class DatabaseDriverFactory : PersistentConnectionFactory { override fun openInMemoryConnection(): SQLiteConnection + override fun resolveDefaultDatabasePath(dbFilename: String): String - override fun openConnection(path: String, openFlags: Int): SQLiteConnection + + override fun openConnection( + path: String, + openFlags: Int, + ): SQLiteConnection } internal expect val inMemoryDriver: InMemoryConnectionFactory diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 8999e12b..fd49f989 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -4,7 +4,9 @@ import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") -public actual class DatabaseDriverFactory: PersistentConnectionFactory, DriverBasedInMemoryFactory(newDriver()) { +public actual class DatabaseDriverFactory : + DriverBasedInMemoryFactory(newDriver()), + PersistentConnectionFactory { actual override fun resolveDefaultDatabasePath(dbFilename: String): String = dbFilename actual override fun openConnection( diff --git a/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt b/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt index 86d6c093..fb2be2cd 100644 --- a/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt +++ b/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt @@ -4,7 +4,7 @@ import androidx.sqlite.SQLiteConnection import com.powersync.sqlite.Database @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) -public actual class DatabaseDriverFactory: PersistentConnectionFactory { +public actual class DatabaseDriverFactory : PersistentConnectionFactory { actual override fun resolveDefaultDatabasePath(dbFilename: String): String = appleDefaultDatabasePath(dbFilename) @OptIn(ExperimentalPowerSyncAPI::class) @@ -22,9 +22,7 @@ public actual class DatabaseDriverFactory: PersistentConnectionFactory { return db } - actual override fun openInMemoryConnection(): SQLiteConnection { - return openConnection(":memory:", 0x02) - } + actual override fun openInMemoryConnection(): SQLiteConnection = openConnection(":memory:", 0x02) } internal actual val inMemoryDriver: InMemoryConnectionFactory = DatabaseDriverFactory() diff --git a/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt b/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt index 2cdbf85a..b09a1ae0 100644 --- a/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt +++ b/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt @@ -5,11 +5,11 @@ import app.cash.sqldelight.async.coroutines.awaitAsOne import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import app.cash.turbine.turbineScope -import com.powersync.DatabaseDriverFactory import com.powersync.PowerSyncDatabase import com.powersync.db.schema.Column import com.powersync.db.schema.Schema import com.powersync.db.schema.Table +import com.powersync.inMemory import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.properties.shouldHaveValue @@ -17,7 +17,6 @@ import io.kotest.matchers.shouldBe import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import kotlinx.io.files.SystemTemporaryDirectory import kotlin.test.Test class SqlDelightTest { diff --git a/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt b/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt index 9e4b6830..ae53fa33 100644 --- a/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt +++ b/internal/testutils/src/platformMain/kotlin/com/powersync/test/TestPlatform.kt @@ -14,6 +14,4 @@ fun cleanup(path: String) { } } -fun getTempDir(): String { - return SystemTemporaryDirectory.toString() -} +fun getTempDir(): String = SystemTemporaryDirectory.toString() From f5cd4c772a2b83ab6ea683635c1f2450b0053249 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 20 Oct 2025 09:34:05 +0200 Subject: [PATCH 35/41] Fix supabase test --- .../com/powersync/connector/supabase/SupabaseConnectorTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt b/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt index 14728a6b..711c17c9 100644 --- a/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt +++ b/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt @@ -6,6 +6,7 @@ import com.powersync.db.crud.CrudTransaction import com.powersync.db.schema.Column import com.powersync.db.schema.Schema import com.powersync.db.schema.Table +import com.powersync.inMemory import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.shouldBe From 23cb1d9d8caeb4a53ca1c658b5dfdf1d1c3ebf9a Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 20 Oct 2025 11:05:11 +0200 Subject: [PATCH 36/41] Fix watchos tests --- .../kotlin/com/powersync/PowerSyncDatabase.kt | 29 +++++++++++++++++- .../com/powersync/db/ActiveInstanceStore.kt | 30 ++++++++----------- .../kotlin/com/powersync/PowerSyncDatabase.kt | 22 ++++---------- .../powersync/DatabaseDriverFactory.native.kt | 17 +++++++---- 4 files changed, 58 insertions(+), 40 deletions(-) diff --git a/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 30d45980..ea96cbeb 100644 --- a/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -247,8 +247,35 @@ public interface PowerSyncDatabase : Queries { return openedWithGroup(pool, scope, schema, logger, group) } + /** + * Creates a PowerSync database backed by a single in-memory database connection opened from the + * [InMemoryConnectionFactory]. + * + * This can be useful for writing tests relying on PowerSync databases. + */ + @ExperimentalPowerSyncAPI + public fun openInMemory( + factory: InMemoryConnectionFactory, + schema: Schema, + scope: CoroutineScope, + logger: Logger? = null, + ): PowerSyncDatabase { + val logger = generateLogger(logger) + // Since this returns a fresh in-memory database every time, use a fresh group to avoid warnings about the + // same database being opened multiple times. + val collection = ActiveDatabaseGroup.GroupsCollection().referenceDatabase(logger, "test") + + return openedWithGroup( + SingleConnectionPool(factory.openInMemoryConnection()), + scope, + schema, + logger, + collection, + ) + } + @ExperimentalPowerSyncAPI - public fun openedWithGroup( + internal fun openedWithGroup( pool: SQLiteConnectionPool, scope: CoroutineScope, schema: Schema, diff --git a/common/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt b/common/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt index e6d011d0..1ba3faed 100644 --- a/common/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt +++ b/common/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.sync.Mutex internal expect fun disposeWhenDeallocated(resource: ActiveDatabaseResource): Any /** - * A collection of PowerSync databases with the same path / identifier. + * An collection of PowerSync databases with the same path / identifier. * * We expect that each group will only ever have one database because we encourage users to write their databases as * singletons. We print a warning when two databases are part of the same group. @@ -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 internal constructor( - internal 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,13 +36,7 @@ public class ActiveDatabaseGroup internal constructor( } } - /** - * A collection of [ActiveDatabaseGroup]s. - * - * Typically, one uses the singleton instance that is the companion object of that class, but separate groups can be - * used for testing. - */ - public open class GroupsCollection : Synchronizable() { + internal open class GroupsCollection : Synchronizable() { internal val allGroups = mutableListOf() private fun findGroup( @@ -67,7 +61,7 @@ public class ActiveDatabaseGroup internal constructor( resolvedGroup } - public fun referenceDatabase( + internal fun referenceDatabase( warnOnDuplicate: Logger, identifier: String, ): Pair { @@ -78,7 +72,7 @@ public class ActiveDatabaseGroup internal constructor( } } - public companion object : GroupsCollection() { + companion object : GroupsCollection() { internal val multipleInstancesMessage = """ Multiple PowerSync instances for the same database have been detected. @@ -88,13 +82,13 @@ public class ActiveDatabaseGroup internal constructor( } } -public class ActiveDatabaseResource( - internal val group: ActiveDatabaseGroup, +internal class ActiveDatabaseResource( + val group: ActiveDatabaseGroup, ) { - internal val disposed = AtomicBoolean(false) + val disposed = AtomicBoolean(false) - public fun dispose() { - if (disposed.compareAndSet(expected = false, new = true)) { + fun dispose() { + if (disposed.compareAndSet(false, true)) { group.removeUsage() } } diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 34f19203..e460d6f1 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -1,10 +1,7 @@ package com.powersync import co.touchlab.kermit.Logger -import com.powersync.db.ActiveDatabaseGroup -import com.powersync.db.driver.SingleConnectionPool import com.powersync.db.schema.Schema -import com.powersync.utils.generateLogger import kotlinx.coroutines.CoroutineScope /** @@ -15,17 +12,10 @@ public fun PowerSyncDatabase.PowerSyncOpenFactory.inMemory( schema: Schema, scope: CoroutineScope, logger: Logger? = null, -): PowerSyncDatabase { - val logger = generateLogger(logger) - // Since this returns a fresh in-memory database every time, use a fresh group to avoid warnings about the - // same database being opened multiple times. - val collection = ActiveDatabaseGroup.GroupsCollection().referenceDatabase(logger, "test") - - return openedWithGroup( - SingleConnectionPool(inMemoryDriver.openInMemoryConnection()), - scope, - schema, - logger, - collection, +): PowerSyncDatabase = + openInMemory( + factory = inMemoryDriver, + schema = schema, + scope = scope, + logger = logger, ) -} diff --git a/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt b/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt index fb2be2cd..bc34bfd5 100644 --- a/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt +++ b/core/src/nativeMain/kotlin/com/powersync/DatabaseDriverFactory.native.kt @@ -12,13 +12,20 @@ public actual class DatabaseDriverFactory : PersistentConnectionFactory { path: String, openFlags: Int, ): SQLiteConnection { + // On some platforms, most notably watchOS, there's no dynamic extension loading and the core extension is + // registered via sqlite3_auto_extension. + val extensionPath = resolvePowerSyncLoadableExtensionPath() + val db = Database.open(path, openFlags) - try { - db.loadExtension(resolvePowerSyncLoadableExtensionPath()!!, "sqlite3_powersync_init") - } catch (e: PowerSyncException) { - db.close() - throw e + extensionPath?.let { path -> + try { + db.loadExtension(path, "sqlite3_powersync_init") + } catch (e: PowerSyncException) { + db.close() + throw e + } } + return db } From 3f0d775abc0d9b23ff2b61c50531ec2116c4ef5c Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 22 Oct 2025 17:52:17 +0200 Subject: [PATCH 37/41] update connection pool --- .../kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt index 0aeb51d0..8eb8a105 100644 --- a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt +++ b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt @@ -99,7 +99,7 @@ public fun openPowerSyncWithPool( schema: Schema, logger: Logger, ): PowerSyncDatabase = - PowerSyncDatabase.Companion.opened( + PowerSyncDatabase.opened( pool = pool, scope = GlobalScope, schema = schema, From d26b9c62776ae13bfda78b1dff93a85cdba6bb48 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 22 Oct 2025 17:52:28 +0200 Subject: [PATCH 38/41] refactor withSession for return type --- .../kotlin/com/powersync/pool/WithSession.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt index f40783e0..08131565 100644 --- a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt +++ b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt @@ -26,6 +26,11 @@ import kotlinx.cinterop.ptr import kotlinx.cinterop.toKString import kotlinx.cinterop.value +public data class SessionResult( + val powerSyncResult: PowerSyncResult, + val affectedTables: Set, +) + /** * We typically have a few options for table update hooks: * 1.) Registering a hook with SQLite @@ -51,9 +56,8 @@ import kotlinx.cinterop.value @Throws(PowerSyncException::class) public fun withSession( db: CPointer, - onComplete: (PowerSyncResult, Set) -> Unit, block: () -> PowerSyncResult, -): Unit = +): SessionResult = runWrapped { memScoped { val sessionPtr = alloc>() @@ -95,8 +99,10 @@ public fun withSession( val changeset = changesetPtr.value if (changesetSize == 0 || changeset == null) { - onComplete(result, emptySet()) - return@memScoped + return@memScoped SessionResult( + result, + affectedTables = emptySet(), + ) } // Parse the changeset to extract table names @@ -112,8 +118,10 @@ public fun withSession( val iter = iterPtr.value if (iter == null) { - onComplete(result, emptySet()) - return@memScoped + return@memScoped SessionResult( + result, + affectedTables = emptySet(), + ) } try { @@ -147,8 +155,10 @@ public fun withSession( sqlite3_free(changeset) } - onComplete(result, changedTables.toSet()) - return@memScoped + return@memScoped SessionResult( + result, + affectedTables = changedTables.toSet(), + ) } finally { // Clean up the session sqlite3session_delete(session) From 81ef883f673dd9ac48c08108d8cd915e0575d718 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 22 Oct 2025 17:52:28 +0200 Subject: [PATCH 39/41] refactor withSession for return type --- .../kotlin/com/powersync/pool/WithSession.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt index f40783e0..08131565 100644 --- a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt +++ b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt @@ -26,6 +26,11 @@ import kotlinx.cinterop.ptr import kotlinx.cinterop.toKString import kotlinx.cinterop.value +public data class SessionResult( + val powerSyncResult: PowerSyncResult, + val affectedTables: Set, +) + /** * We typically have a few options for table update hooks: * 1.) Registering a hook with SQLite @@ -51,9 +56,8 @@ import kotlinx.cinterop.value @Throws(PowerSyncException::class) public fun withSession( db: CPointer, - onComplete: (PowerSyncResult, Set) -> Unit, block: () -> PowerSyncResult, -): Unit = +): SessionResult = runWrapped { memScoped { val sessionPtr = alloc>() @@ -95,8 +99,10 @@ public fun withSession( val changeset = changesetPtr.value if (changesetSize == 0 || changeset == null) { - onComplete(result, emptySet()) - return@memScoped + return@memScoped SessionResult( + result, + affectedTables = emptySet(), + ) } // Parse the changeset to extract table names @@ -112,8 +118,10 @@ public fun withSession( val iter = iterPtr.value if (iter == null) { - onComplete(result, emptySet()) - return@memScoped + return@memScoped SessionResult( + result, + affectedTables = emptySet(), + ) } try { @@ -147,8 +155,10 @@ public fun withSession( sqlite3_free(changeset) } - onComplete(result, changedTables.toSet()) - return@memScoped + return@memScoped SessionResult( + result, + affectedTables = changedTables.toSet(), + ) } finally { // Clean up the session sqlite3session_delete(session) From 9641e3b5627287d11039fc2427adeae30b6026ab Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 22 Oct 2025 18:38:17 +0200 Subject: [PATCH 40/41] Helpers for Swift Strict Concurrency --- .../pool/SwiftSQLiteConnectionPool.kt | 47 +++++++++---------- .../kotlin/com/powersync/pool/WithSession.kt | 2 +- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt index 0aeb51d0..2bceb75d 100644 --- a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt +++ b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SwiftSQLiteConnectionPool.kt @@ -1,14 +1,11 @@ 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 @@ -59,34 +56,36 @@ public class SwiftSQLiteConnectionPool( return result as T } - override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { - var result: T? = null - adapter.leaseWrite { lease -> - runWrapped { - val connectionLease = RawConnectionLease(lease) - runBlocking { - result = callback(connectionLease) + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T = + runWrapped { + var result: T? = null + adapter.leaseWrite { lease -> + runWrapped { + val connectionLease = RawConnectionLease(lease) + runBlocking { + result = callback(connectionLease) + } } } + @Suppress("UNCHECKED_CAST") + return result as T } - @Suppress("UNCHECKED_CAST") - return result as T - } - 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 withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R): Unit = + runWrapped { + adapter.leaseAll { writerLease, readerLeases -> + runWrapped { + runBlocking { + action( + RawConnectionLease(writerLease), + readerLeases.map { + RawConnectionLease(it) + }, + ) + } } } } - } override suspend fun close() { adapter.dispose() diff --git a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt index 08131565..c8173e9a 100644 --- a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt +++ b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt @@ -27,7 +27,7 @@ import kotlinx.cinterop.toKString import kotlinx.cinterop.value public data class SessionResult( - val powerSyncResult: PowerSyncResult, + val blockResult: PowerSyncResult, val affectedTables: Set, ) From 222e5751682d8388d2fc5aa38d7a2f1b4672ecea Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 23 Oct 2025 17:23:12 +0200 Subject: [PATCH 41/41] Linter fix --- .../com/powersync/pool/{WithSession.kt => SessionResult.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/{WithSession.kt => SessionResult.kt} (100%) diff --git a/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt b/internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SessionResult.kt similarity index 100% rename from internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/WithSession.kt rename to internal/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/pool/SessionResult.kt