diff --git a/Package.swift b/Package.swift index a6c2ad52..9411969a 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( targets: [ .binaryTarget( name: packageName, - path: "./PowerSyncKotlin/build/XCFrameworks/debug/PowerSyncKotlin.xcframework" + path: "./PowerSyncKotlin/build/XCFrameworks/debug/\(packageName).xcframework" ) , ] diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 85d3c512..2755591b 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -24,9 +24,9 @@ public actual class DatabaseDriverFactory( @Suppress("unused") private fun onTransactionCommit(success: Boolean) { driver?.also { driver -> - if (success) { - driver.fireTableUpdates() - } else { + // Only clear updates if a rollback happened + // We manually fire updates when transactions are completed + if (!success) { driver.clearTableUpdates() } } @@ -42,38 +42,38 @@ public actual class DatabaseDriverFactory( PsSqlDriver( scope = scope, driver = - AndroidSqliteDriver( - context = context, - schema = schema, - name = dbFilename, - factory = - RequerySQLiteOpenHelperFactory( - listOf( - RequerySQLiteOpenHelperFactory.ConfigurationOptions { config -> - config.customExtensions.add( - SQLiteCustomExtension( - "libpowersync", - "sqlite3_powersync_init", - ), - ) - config.customExtensions.add( - SQLiteCustomExtension( - "libpowersync-sqlite", - "powersync_init", - ), - ) - config - }, - ), - ), - callback = - object : AndroidSqliteDriver.Callback(schema) { - override fun onConfigure(db: SupportSQLiteDatabase) { - db.enableWriteAheadLogging() - super.onConfigure(db) - } + AndroidSqliteDriver( + context = context, + schema = schema, + name = dbFilename, + factory = + RequerySQLiteOpenHelperFactory( + listOf( + RequerySQLiteOpenHelperFactory.ConfigurationOptions { config -> + config.customExtensions.add( + SQLiteCustomExtension( + "libpowersync", + "sqlite3_powersync_init", + ), + ) + config.customExtensions.add( + SQLiteCustomExtension( + "libpowersync-sqlite", + "powersync_init", + ), + ) + config }, + ), ), + callback = + object : AndroidSqliteDriver.Callback(schema) { + override fun onConfigure(db: SupportSQLiteDatabase) { + db.enableWriteAheadLogging() + super.onConfigure(db) + } + }, + ), ) setupSqliteBinding() return this.driver as PsSqlDriver 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 d8252877..43abf0f5 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -20,8 +20,11 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString @@ -33,6 +36,12 @@ internal class InternalDatabaseImpl( override val transactor: PsDatabase = PsDatabase(driver) override val queries: PowersyncQueries = transactor.powersyncQueries + // Register callback for table updates + private fun tableUpdates(): Flow> = driver.tableUpdates() + + // Debounced by transaction completion + private val tableUpdatesMutex = Mutex() + // Could be scope.coroutineContext, but the default is GlobalScope, which seems like a bad idea. To discuss. private val dbContext = Dispatchers.IO private val transaction = @@ -62,22 +71,31 @@ internal class InternalDatabaseImpl( } companion object { - const val POWERSYNC_TABLE_MATCH: String = "(^ps_data__|^ps_data_local__)" - const val DEFAULT_WATCH_THROTTLE_MS: Long = 30L + const val POWERSYNC_TABLE_MATCH = "(^ps_data__|^ps_data_local__)" + const val DEFAULT_WATCH_THROTTLE_MS = 30L } init { scope.launch { val accumulatedUpdates = mutableSetOf() + // Store table changes in an accumulated array which will be (debounced) emitted on transaction end tableUpdates() -// Debounce will discard any events which occur inside the debounce window -// This will accumulate those table updates - .onEach { tables -> accumulatedUpdates.addAll(tables) } + .onEach { tables -> + val dataTables = + tables + .map { toFriendlyTableName(it) } + .filter { it.isNotBlank() } + tableUpdatesMutex.withLock { + accumulatedUpdates.addAll(dataTables) + } + } + // debounce ignores events inside the throttle. Debouncing needs to be done after accumulation .debounce(DEFAULT_WATCH_THROTTLE_MS) - .collect { - val dataTables = accumulatedUpdates.map { toFriendlyTableName(it) }.filter { it.isNotBlank() } - driver.notifyListeners(queryKeys = dataTables.toTypedArray()) - accumulatedUpdates.clear() + .collect { _ -> + tableUpdatesMutex.withLock { + driver.notifyListeners(queryKeys = accumulatedUpdates.toTypedArray()) + accumulatedUpdates.clear() + } } } } @@ -85,7 +103,12 @@ internal class InternalDatabaseImpl( override suspend fun execute( sql: String, parameters: List?, - ): Long = withContext(dbContext) { executeSync(sql, parameters) } + ): Long = + withContext(dbContext) { + val r = executeSync(sql, parameters) + driver.fireTableUpdates() + r + } private fun executeSync( sql: String, @@ -233,20 +256,21 @@ internal class InternalDatabaseImpl( override suspend fun writeTransaction(callback: ThrowableTransactionCallback): R = withContext(dbContext) { - transactor.transactionWithResult(noEnclosing = true) { - runWrapped { - val result = callback.execute(transaction) - if (result is PowerSyncException) { - throw result + val r = + transactor.transactionWithResult(noEnclosing = true) { + runWrapped { + val result = callback.execute(transaction) + if (result is PowerSyncException) { + throw result + } + result } - result } - } + // Trigger watched queries + driver.fireTableUpdates() + r } - // Register callback for table updates - private fun tableUpdates(): Flow> = driver.tableUpdates() - // Register callback for table updates on a specific table override fun updatesOnTable(tableName: String): Flow = driver.updatesOnTable(tableName) diff --git a/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt b/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt index 2e6ad189..1cd80702 100644 --- a/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt +++ b/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt @@ -40,9 +40,9 @@ public actual class DatabaseDriverFactory { private fun onTransactionCommit(success: Boolean) { driver?.also { driver -> - if (success) { - driver.fireTableUpdates() - } else { + // Only clear updates on rollback + // We manually fire updates when a transaction ended + if (!success) { driver.clearTableUpdates() } } @@ -63,7 +63,8 @@ public actual class DatabaseDriverFactory { override fun eWrite( message: String, exception: Throwable?, - ) {} + ) { + } override fun trace(message: String) {} @@ -74,27 +75,27 @@ public actual class DatabaseDriverFactory { PsSqlDriver( scope = scope, driver = - NativeSqliteDriver( - configuration = - DatabaseConfiguration( - name = dbFilename, - version = schema.version.toInt(), - create = { connection -> wrapConnection(connection) { schema.create(it) } }, - loggingConfig = Logging(logger = sqlLogger), - lifecycleConfig = - DatabaseConfiguration.Lifecycle( - onCreateConnection = { connection -> - setupSqliteBinding(connection) - wrapConnection(connection) { driver -> - schema.create(driver) - } - }, - onCloseConnection = { connection -> - deregisterSqliteBinding(connection) - }, - ), - ), + NativeSqliteDriver( + configuration = + DatabaseConfiguration( + name = dbFilename, + version = schema.version.toInt(), + create = { connection -> wrapConnection(connection) { schema.create(it) } }, + loggingConfig = Logging(logger = sqlLogger), + lifecycleConfig = + DatabaseConfiguration.Lifecycle( + onCreateConnection = { connection -> + setupSqliteBinding(connection) + wrapConnection(connection) { driver -> + schema.create(driver) + } + }, + onCloseConnection = { connection -> + deregisterSqliteBinding(connection) + }, + ), ), + ), ) return this.driver as PsSqlDriver } diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 4010e507..6b0a09ba 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -21,9 +21,9 @@ public actual class DatabaseDriverFactory { @Suppress("unused") private fun onTransactionCommit(success: Boolean) { driver?.also { driver -> - if (success) { - driver.fireTableUpdates() - } else { + // Only clear updates on rollback + // We manually fire updates when a transaction ended + if (!success) { driver.clearTableUpdates() } } diff --git a/demos/hello-powersync/composeApp/src/androidMain/AndroidManifest.xml b/demos/hello-powersync/composeApp/src/androidMain/AndroidManifest.xml index f0692533..dda1d84f 100644 --- a/demos/hello-powersync/composeApp/src/androidMain/AndroidManifest.xml +++ b/demos/hello-powersync/composeApp/src/androidMain/AndroidManifest.xml @@ -9,7 +9,9 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@android:style/Theme.Material.Light.NoActionBar"> + + + + localhost + + diff --git a/persistence/src/jvmMain/kotlin/com/powersync/persistence/driver/JdbcPreparedStatement.kt b/persistence/src/jvmMain/kotlin/com/powersync/persistence/driver/JdbcPreparedStatement.kt index 59ac7d44..796e5fa9 100644 --- a/persistence/src/jvmMain/kotlin/com/powersync/persistence/driver/JdbcPreparedStatement.kt +++ b/persistence/src/jvmMain/kotlin/com/powersync/persistence/driver/JdbcPreparedStatement.kt @@ -15,11 +15,17 @@ import java.sql.Types public class JdbcPreparedStatement( private val preparedStatement: PreparedStatement, ) : SqlPreparedStatement { - override fun bindBytes(index: Int, bytes: ByteArray?) { + override fun bindBytes( + index: Int, + bytes: ByteArray?, + ) { preparedStatement.setBytes(index + 1, bytes) } - override fun bindBoolean(index: Int, boolean: Boolean?) { + override fun bindBoolean( + index: Int, + boolean: Boolean?, + ) { if (boolean == null) { preparedStatement.setNull(index + 1, Types.BOOLEAN) } else { @@ -27,7 +33,10 @@ public class JdbcPreparedStatement( } } - public fun bindByte(index: Int, byte: Byte?) { + public fun bindByte( + index: Int, + byte: Byte?, + ) { if (byte == null) { preparedStatement.setNull(index + 1, Types.TINYINT) } else { @@ -35,7 +44,10 @@ public class JdbcPreparedStatement( } } - public fun bindShort(index: Int, short: Short?) { + public fun bindShort( + index: Int, + short: Short?, + ) { if (short == null) { preparedStatement.setNull(index + 1, Types.SMALLINT) } else { @@ -43,7 +55,10 @@ public class JdbcPreparedStatement( } } - public fun bindInt(index: Int, int: Int?) { + public fun bindInt( + index: Int, + int: Int?, + ) { if (int == null) { preparedStatement.setNull(index + 1, Types.INTEGER) } else { @@ -51,7 +66,10 @@ public class JdbcPreparedStatement( } } - override fun bindLong(index: Int, long: Long?) { + override fun bindLong( + index: Int, + long: Long?, + ) { if (long == null) { preparedStatement.setNull(index + 1, Types.BIGINT) } else { @@ -59,7 +77,10 @@ public class JdbcPreparedStatement( } } - public fun bindFloat(index: Int, float: Float?) { + public fun bindFloat( + index: Int, + float: Float?, + ) { if (float == null) { preparedStatement.setNull(index + 1, Types.REAL) } else { @@ -67,7 +88,10 @@ public class JdbcPreparedStatement( } } - override fun bindDouble(index: Int, double: Double?) { + override fun bindDouble( + index: Int, + double: Double?, + ) { if (double == null) { preparedStatement.setNull(index + 1, Types.DOUBLE) } else { @@ -75,11 +99,17 @@ public class JdbcPreparedStatement( } } - public fun bindBigDecimal(index: Int, decimal: BigDecimal?) { + public fun bindBigDecimal( + index: Int, + decimal: BigDecimal?, + ) { preparedStatement.setBigDecimal(index + 1, decimal) } - public fun bindObject(index: Int, obj: Any?) { + public fun bindObject( + index: Int, + obj: Any?, + ) { if (obj == null) { preparedStatement.setNull(index + 1, Types.OTHER) } else { @@ -87,7 +117,11 @@ public class JdbcPreparedStatement( } } - public fun bindObject(index: Int, obj: Any?, type: Int) { + public fun bindObject( + index: Int, + obj: Any?, + type: Int, + ) { if (obj == null) { preparedStatement.setNull(index + 1, type) } else { @@ -95,69 +129,99 @@ public class JdbcPreparedStatement( } } - override fun bindString(index: Int, string: String?) { + override fun bindString( + index: Int, + string: String?, + ) { preparedStatement.setString(index + 1, string) } - public fun bindDate(index: Int, date: java.sql.Date?) { + public fun bindDate( + index: Int, + date: java.sql.Date?, + ) { preparedStatement.setDate(index, date) } - public fun bindTime(index: Int, date: java.sql.Time?) { + public fun bindTime( + index: Int, + date: java.sql.Time?, + ) { preparedStatement.setTime(index, date) } - public fun bindTimestamp(index: Int, timestamp: java.sql.Timestamp?) { + public fun bindTimestamp( + index: Int, + timestamp: java.sql.Timestamp?, + ) { preparedStatement.setTimestamp(index, timestamp) } public fun executeQuery(mapper: (SqlCursor) -> R): R { try { - return preparedStatement.executeQuery() + return preparedStatement + .executeQuery() .use { resultSet -> mapper(JdbcCursor(resultSet)) } } finally { preparedStatement.close() } } - public fun execute(): Long { - return if (preparedStatement.execute()) { + 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 { +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) + + 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) + inline fun getObject(index: Int): T? = resultSet.getObject(index + 1, T::class.java) + 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() } + private fun getAtIndex( + index: Int, + converter: (Int) -> T, + ): T? = converter(index + 1).takeUnless { resultSet.wasNull() } override fun next(): QueryResult.Value = QueryResult.Value(resultSet.next()) }