diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ae23eb..447c53fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,23 @@ # Changelog -## 1.0.0-BETA29 - -* Added queing protection and warnings when connecting multiple PowerSync clients to the same database file. - ## 1.0.0-BETA28 * Update PowerSync SQLite core extension to 0.3.12. +* Added queing protection and warnings when connecting multiple PowerSync clients to the same database file. +* Improved concurrent SQLite connection support accross various platforms. All platforms now use a single write connection and multiple read connections for concurrent read queries. +* Added the ability to open a SQLite database given a custom `dbDirectory` path. This is currently not supported on iOS due to internal driver restrictions. +* Internaly improved the linking of SQLite for iOS. +* The Android SQLite driver now uses the [Xerial JDBC library](https://github.com/xerial/sqlite-jdbc). This removes the requirement for users to add the jitpack Maven repository to their projects. +```diff +// settings.gradle.kts example + repositories { + google() +- maven("https://jitpack.io") { +- content { includeGroup("com.github.requery") } +- } + mavenCentral() + } +``` ## 1.0.0-BETA27 diff --git a/README.md b/README.md index 3217de99..d10e1d23 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,9 @@ The PowerSync Kotlin Multiplatform SDK is currently in a beta release. It can be Current limitations: - Integration with SQLDelight schema and API generation (ORM) is not yet supported. -- Supports only a single database file. Future work/ideas: - -- Improved error handling. - Attachments helper package. -- Management of DB connections on each platform natively. -- Supporting additional targets (JVM, Wasm). ## Installation diff --git a/build.gradle.kts b/build.gradle.kts index d22c8854..da36a00a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,9 +27,6 @@ allprojects { maven("https://cache-redirector.jetbrains.com/intellij-dependencies") // Repo for the backported Android IntelliJ Plugin by Jetbrains used in Ultimate maven("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/kotlin-ide-plugin-dependencies/") - maven("https://jitpack.io") { - content { includeGroup("com.github.requery") } - } } configurations.configureEach { diff --git a/core-tests-android/src/androidTest/java/com/powersync/AndroidDatabaseTest.kt b/core-tests-android/src/androidTest/java/com/powersync/AndroidDatabaseTest.kt index 182c7560..39c289f2 100644 --- a/core-tests-android/src/androidTest/java/com/powersync/AndroidDatabaseTest.kt +++ b/core-tests-android/src/androidTest/java/com/powersync/AndroidDatabaseTest.kt @@ -93,4 +93,133 @@ class AndroidDatabaseTest { query.cancel() } } + + @Test + fun testConcurrentReads() = + runTest { + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + val pausedTransaction = CompletableDeferred() + val transactionItemCreated = CompletableDeferred() + // Start a long running writeTransaction + val transactionJob = + async { + database.writeTransaction { tx -> + // Create another user + // External readers should not see this user while the transaction is open + tx.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + transactionItemCreated.complete(Unit) + + // Block this transaction until we free it + runBlocking { + pausedTransaction.await() + } + } + } + + // Make sure to wait for the item to have been created in the transaction + transactionItemCreated.await() + // Try and read while the write transaction is busy + val result = database.getAll("SELECT * FROM users") { UserRow.from(it) } + // The transaction is not commited yet, we should only read 1 user + assertEquals(result.size, 1) + + // Let the transaction complete + pausedTransaction.complete(Unit) + transactionJob.await() + + val afterTx = database.getAll("SELECT * FROM users") { UserRow.from(it) } + assertEquals(afterTx.size, 2) + } + + @Test + fun transactionReads() = + runTest { + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + database.writeTransaction { tx -> + val userCount = + tx.getAll("SELECT COUNT(*) as count FROM users") { cursor -> cursor.getLong(0)!! } + assertEquals(userCount[0], 1) + + tx.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + // Getters inside the transaction should be able to see the latest update + val userCount2 = + tx.getAll("SELECT COUNT(*) as count FROM users") { cursor -> cursor.getLong(0)!! } + assertEquals(userCount2[0], 2) + } + } + + @Test + fun openDBWithDirectory() = + runTest { + val tempDir = + InstrumentationRegistry + .getInstrumentation() + .targetContext.cacheDir.canonicalPath + val dbFilename = "testdb" + + val db = + PowerSyncDatabase( + factory = DatabaseDriverFactory(InstrumentationRegistry.getInstrumentation().targetContext), + schema = Schema(UserRow.table), + dbDirectory = tempDir, + dbFilename = dbFilename, + ) + + val path = db.get("SELECT file FROM pragma_database_list;") { it.getString(0)!! } + + assertEquals(path.contains(tempDir), true) + + db.close() + } + + @Test + fun readConnectionsReadOnly() = + runTest { + val exception = + assertThrows(PowerSyncException::class.java) { + // This version of assertThrows does not support suspending functions + runBlocking { + database.getOptional( + """ + INSERT INTO + users (id, name, email) + VALUES + (uuid(), ?, ?) + RETURNING * + """.trimIndent(), + parameters = listOf("steven", "steven@journeyapps.com"), + ) {} + } + } + // The exception messages differ slightly between drivers + assertEquals(exception.message!!.contains("write a readonly database"), true) + } } diff --git a/core/.gitignore b/core/.gitignore index 09328be3..983737fa 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -1 +1,5 @@ binaries/ +# Required for the JDBC SQLite driver, but should not be commiteed +src/androidMain/jni + +testdb-* \ No newline at end of file diff --git a/core/README.md b/core/README.md index 9f40d0ac..db89fbb3 100644 --- a/core/README.md +++ b/core/README.md @@ -1,19 +1,31 @@ # PowerSync core module -The PowerSync core module provides the core functionality for the PowerSync Kotlin Multiplatform SDK. +The PowerSync core module provides the core functionality for the PowerSync Kotlin Multiplatform +SDK. ## Structure -This is a Kotlin Multiplatform project targeting Android, iOS platforms, with the following 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 +- `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. -- `androidMain` - Android specific code, which includes a implementation of `DatabaseDriverFactory` class that creates an instance of `app.cash.sqldelight.driver.android.AndroidSqliteDriver` using - a `io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory`. It also includes native SQLite bindings for Android. -- `iosMain` - iOS specific code, which includes a implementation of `DatabaseDriverFactory` class that creates an instance of `app.cash.sqldelight.driver.native.NativeSqliteDriver` and also sets up native SQLite bindings for iOS. +- `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://cashapp.github.io/sqldelight) for it database API and typesafe database query generation. +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. +The PowerSync core module does not currently support integrating with SQLDelight from client +applications. diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 648a1444..96f77274 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,14 +1,16 @@ -import com.android.build.gradle.internal.tasks.factory.dependsOn import com.powersync.plugins.sonatype.setupGithubRepository 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.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest +import org.jetbrains.kotlin.gradle.tasks.KotlinTest import org.jetbrains.kotlin.konan.target.Family + plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinSerialization) @@ -21,75 +23,26 @@ plugins { alias(libs.plugins.kotlin.atomicfu) } -val sqliteVersion = "3450200" -val sqliteReleaseYear = "2024" - -val sqliteSrcFolder = - project.layout.buildDirectory - .dir("native/sqlite") - .get() - -val downloadSQLiteSources by tasks.registering(Download::class) { - val zipFileName = "sqlite-amalgamation-$sqliteVersion.zip" - val destination = sqliteSrcFolder.file(zipFileName).asFile - src("https://www.sqlite.org/$sqliteReleaseYear/$zipFileName") - dest(destination) - onlyIfNewer(true) - overwrite(false) -} - -val unzipSQLiteSources by tasks.registering(Copy::class) { - dependsOn(downloadSQLiteSources) - - from( - zipTree(downloadSQLiteSources.get().dest).matching { - include("*/sqlite3.*") - exclude { - it.isDirectory - } - eachFile { - this.path = this.name - } - }, - ) - into(sqliteSrcFolder) -} - -val buildCInteropDef by tasks.registering { - dependsOn(unzipSQLiteSources) - - val interopFolder = - project.layout.buildDirectory - .dir("interop/sqlite") - .get() - - val cFile = sqliteSrcFolder.file("sqlite3.c").asFile - val defFile = interopFolder.file("sqlite3.def").asFile - - doFirst { - defFile.writeText( - """ - package = com.powersync.sqlite3 - --- - - """.trimIndent() + cFile.readText(), - ) - } - outputs.files(defFile) -} - 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 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 @@ -99,26 +52,32 @@ val downloadPowersyncDesktopBinaries by tasks.registering(Download::class) { 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 (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") - }) + 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 downloadPowersyncFramework by tasks.registering(Download::class) { - val coreVersion = libs.versions.powersync.core.get() - val framework = "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/powersync-sqlite-core.xcframework.zip" + val coreVersion = + libs.versions.powersync.core + .get() + val framework = + "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/powersync-sqlite-core.xcframework.zip" src(framework) dest(binariesFolder.map { it.file("framework/powersync-sqlite-core.xcframework.zip") }) @@ -136,6 +95,60 @@ val unzipPowersyncFramework by tasks.registering(Copy::class) { into(binariesFolder.map { it.dir("framework") }) } +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 +} + kotlin { androidTarget { publishLibraryVariants("release", "debug") @@ -163,23 +176,17 @@ kotlin { compileTaskProvider { compilerOptions.freeCompilerArgs.add("-Xexport-kdoc") } - cinterops.create("sqlite") { - val cInteropTask = tasks[interopProcessingTaskName] - cInteropTask.dependsOn(buildCInteropDef) - definitionFile = - buildCInteropDef - .get() - .outputs.files.singleFile - compilerOpts.addAll(listOf("-DHAVE_GETHOSTUUID=0")) - } - cinterops.create("powersync-sqlite-core") } if (konanTarget.family == Family.IOS && konanTarget.name.contains("simulator")) { binaries.withType().configureEach { - linkTaskProvider.dependsOn(unzipPowersyncFramework) + linkTaskProvider.configure { dependsOn(unzipPowersyncFramework) } linkerOpts("-framework", "powersync-sqlite-core") - val frameworkRoot = binariesFolder.map { it.dir("framework/powersync-sqlite-core.xcframework/ios-arm64_x86_64-simulator") }.get().asFile.path + val frameworkRoot = + binariesFolder + .map { it.dir("framework/powersync-sqlite-core.xcframework/ios-arm64_x86_64-simulator") } + .get() + .asFile.path linkerOpts("-F", frameworkRoot) linkerOpts("-rpath", frameworkRoot) @@ -212,8 +219,10 @@ kotlin { } val commonJava by creating { - kotlin.srcDir("commonJava") dependsOn(commonMain.get()) + dependencies { + implementation(libs.sqlite.jdbc) + } } commonMain.dependencies { @@ -227,7 +236,7 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.stately.concurrency) implementation(libs.configuration.annotations) - api(project(":persistence")) + api(projects.persistence) api(libs.kermit) } @@ -241,7 +250,6 @@ kotlin { dependencies { implementation(libs.ktor.client.okhttp) - implementation(libs.sqlite.jdbc) } } @@ -296,23 +304,19 @@ android { .get() .toInt() consumerProguardFiles("proguard-rules.pro") + } - @Suppress("UnstableApiUsage") - externalNativeBuild { - cmake { - arguments.addAll( - listOf( - "-DSQLITE3_SRC_DIR=${sqliteSrcFolder.asFile.absolutePath}", - ), - ) - } + sourceSets { + getByName("main") { + jniLibs.srcDirs("src/androidMain/jni", "src/main/jni", "src/jniLibs") } } + ndkVersion = "27.1.12297006" +} - externalNativeBuild { - cmake { - path = project.file("src/androidMain/cpp/CMakeLists.txt") - } +androidComponents.onVariants { + tasks.named("preBuild") { + dependsOn(moveJDBCJNIFiles) } } @@ -322,9 +326,10 @@ tasks.named(kotlin.jvm().compilations["main"].processResources // 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) - } + javaLauncher = + javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(8) + } description = "Run tests with Java 8" group = LifecycleBasePlugin.VERIFICATION_GROUP @@ -336,22 +341,12 @@ val testWithJava8 by tasks.registering(KotlinJvmTest::class) { } tasks.named("check").configure { dependsOn(testWithJava8) } -afterEvaluate { - val buildTasks = - tasks.matching { - val taskName = it.name - if (taskName.contains("Clean")) { - return@matching false - } - if (taskName.contains("externalNative") || taskName.contains("CMake") || taskName.contains("generateJsonModel")) { - return@matching true - } - return@matching false - } - - buildTasks.forEach { - it.dependsOn(buildCInteropDef) +tasks.withType { + testLogging { + events("PASSED", "FAILED", "SKIPPED") + exceptionFormat = TestExceptionFormat.FULL + showStandardStreams = true + showStackTraces = true } } - setupGithubRepository() diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro index aeed3990..60e7ccdf 100644 --- a/core/proguard-rules.pro +++ b/core/proguard-rules.pro @@ -9,3 +9,5 @@ private void onTableUpdate(java.lang.String); private void onTransactionCommit(boolean); } +-keep class org.sqlite.** { *; } +-dontwarn java.sql.JDBCType \ No newline at end of file diff --git a/core/src/androidMain/cpp/CMakeLists.txt b/core/src/androidMain/cpp/CMakeLists.txt deleted file mode 100644 index c80f14dd..00000000 --- a/core/src/androidMain/cpp/CMakeLists.txt +++ /dev/null @@ -1,25 +0,0 @@ -cmake_minimum_required(VERSION 3.18.1) - -project(powersync-sqlite) - -set(PACKAGE_NAME "powersync-sqlite") -set(BUILD_DIR ${CMAKE_SOURCE_DIR}/build) - -add_library( - ${PACKAGE_NAME} - SHARED - ../../jvmNative/cpp/sqlite_bindings.cpp - "${SQLITE3_SRC_DIR}/sqlite3.c" -) - -target_include_directories( - ${PACKAGE_NAME} - PRIVATE - "${SQLITE3_SRC_DIR}" -) - -target_link_libraries( - ${PACKAGE_NAME} - log - android -) \ No newline at end of file diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 090be5e1..3354c9d1 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -1,87 +1,62 @@ package com.powersync import android.content.Context -import androidx.sqlite.db.SupportSQLiteDatabase +import com.powersync.db.JdbcSqliteDriver +import com.powersync.db.buildDefaultWalProperties import com.powersync.db.internal.InternalSchema -import com.powersync.persistence.driver.AndroidSqliteDriver -import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory -import io.requery.android.database.sqlite.SQLiteCustomExtension +import com.powersync.db.migrateDriver import kotlinx.coroutines.CoroutineScope +import org.sqlite.SQLiteCommitListener @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public actual class DatabaseDriverFactory( private val context: Context, ) { - private var driver: PsSqlDriver? = null - - private external fun setupSqliteBinding() - - @Suppress("unused") - private fun onTableUpdate(tableName: String) { - driver?.updateTable(tableName) - } - - @Suppress("unused") - private fun onTransactionCommit(success: Boolean) { - driver?.also { driver -> - // Only clear updates if a rollback happened - // We manually fire updates when transactions are completed - if (!success) { - driver.clearTableUpdates() - } - } - } - internal actual fun createDriver( scope: CoroutineScope, dbFilename: String, + dbDirectory: String?, + readOnly: Boolean, ): PsSqlDriver { val schema = InternalSchema - this.driver = - 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) - } - }, - ), + val dbPath = + if (dbDirectory != null) { + "$dbDirectory/$dbFilename" + } else { + context.getDatabasePath(dbFilename) + } + + val driver = + JdbcSqliteDriver( + url = "jdbc:sqlite:$dbPath", + properties = buildDefaultWalProperties(readOnly = readOnly), ) - setupSqliteBinding() - return this.driver as PsSqlDriver - } - public companion object { - init { - System.loadLibrary("powersync-sqlite") + migrateDriver(driver, schema) + + driver.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 } } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 6a72e27c..da482cd0 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -7,16 +7,26 @@ import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import co.touchlab.kermit.TestLogWriter import com.powersync.db.ActiveDatabaseGroup +import com.powersync.db.getString import com.powersync.db.schema.Schema import com.powersync.testutils.UserRow +import com.powersync.testutils.generatePrintLogWriter +import com.powersync.testutils.getTempDir import com.powersync.testutils.waitFor +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull +import kotlin.test.assertTrue @OptIn(ExperimentalKermitApi::class) class DatabaseTest { @@ -29,7 +39,7 @@ class DatabaseTest { Logger( TestConfig( minSeverity = Severity.Debug, - logWriterList = listOf(logWriter), + logWriterList = listOf(logWriter, generatePrintLogWriter()), ), ) @@ -56,14 +66,122 @@ class DatabaseTest { @AfterTest fun tearDown() { - runBlocking { database.disconnectAndClear(true) } + runBlocking { + if (!database.closed) { + database.disconnectAndClear(true) + } + } com.powersync.testutils.cleanup("testdb") } @Test fun testLinksPowerSync() = runTest { - database.get("SELECT powersync_rs_version() AS r;") { it.getString(0)!! } + database.get("SELECT powersync_rs_version();") { it.getString(0)!! } + } + + @Test + fun testWAL() = + runTest { + val mode = + database.get( + "PRAGMA journal_mode", + mapper = { it.getString(0)!! }, + ) + assertEquals(mode, "wal") + } + + @Test + fun testFTS() = + runTest { + val mode = + database.get( + "SELECT sqlite_compileoption_used('ENABLE_FTS5');", + mapper = { it.getLong(0)!! }, + ) + assertEquals(mode, 1) + } + + @Test + fun testConcurrentReads() = + runTest { + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + val pausedTransaction = CompletableDeferred() + val transactionItemCreated = CompletableDeferred() + // Start a long running writeTransaction + val transactionJob = + async { + database.writeTransaction { tx -> + // Create another user + // External readers should not see this user while the transaction is open + tx.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + transactionItemCreated.complete(Unit) + + // Block this transaction until we free it + runBlocking { + pausedTransaction.await() + } + } + } + + // Make sure to wait for the item to have been created in the transaction + transactionItemCreated.await() + // Try and read while the write transaction is busy + val result = database.getAll("SELECT * FROM users") { UserRow.from(it) } + // The transaction is not commited yet, we should only read 1 user + assertEquals(result.size, 1) + + // Let the transaction complete + pausedTransaction.complete(Unit) + transactionJob.await() + + val afterTx = database.getAll("SELECT * FROM users") { UserRow.from(it) } + assertEquals(afterTx.size, 2) + } + + @Test + fun testTransactionReads() = + runTest { + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + database.writeTransaction { tx -> + val userCount = + tx.getAll("SELECT COUNT(*) as count FROM users") { cursor -> cursor.getLong(0)!! } + assertEquals(userCount[0], 1) + + tx.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + // Getters inside the transaction should be able to see the latest update + val userCount2 = + tx.getAll("SELECT COUNT(*) as count FROM users") { cursor -> cursor.getLong(0)!! } + assertEquals(userCount2[0], 2) + } } @Test @@ -97,7 +215,7 @@ class DatabaseTest { try { database.writeTransaction { it.execute("DELETE FROM users;") - it.execute("syntax error, revert please") + it.execute("syntax error, revert please (this is intentional from the unit test)") } } catch (e: Exception) { // Ignore @@ -114,6 +232,74 @@ class DatabaseTest { } } + @Test + fun testClosingReadPool() = + runTest { + val pausedLock = CompletableDeferred() + val inLock = CompletableDeferred() + // Request a lock + val lockJob = + async { + database.readLock { + inLock.complete(Unit) + runBlocking { + pausedLock.await() + } + } + } + + // Wait for the lock to be active + inLock.await() + + // Close the database. This should close the read pool + // The pool should wait for jobs to complete before closing + val closeJob = + async { + database.close() + } + + // Wait a little for testing + // Spawns in a different context for the delay to actually take affect + async { withContext(Dispatchers.Default) { delay(500) } }.await() + + // The database should not close yet + assertEquals(actual = database.closed, expected = false) + + // Any new readLocks should throw + val exception = assertFailsWith { database.readLock {} } + assertEquals(expected = "Cannot process connection pool request", actual = exception.message) + // Release the lock + pausedLock.complete(Unit) + lockJob.await() + closeJob.await() + + assertEquals(actual = database.closed, expected = true) + } + + @Test + fun openDBWithDirectory() = + runTest { + val tempDir = + getTempDir() + ?: // SQLiteR, which is used on iOS, does not support opening dbs from directories + return@runTest + + val dbFilename = "testdb" + + val db = + PowerSyncDatabase( + factory = com.powersync.testutils.factory, + schema = Schema(UserRow.table), + dbFilename = dbFilename, + dbDirectory = getTempDir(), + logger = logger, + ) + + val path = db.get("SELECT file FROM pragma_database_list;") { it.getString(0)!! } + assertTrue { path.contains(tempDir) } + db.close() + } + @Test fun warnsMultipleInstances() = runTest { @@ -128,4 +314,34 @@ class DatabaseTest { } db2.close() } + + @Test + fun readConnectionsReadOnly() = + runTest { + val exception = + assertFailsWith { + database.getOptional( + """ + INSERT INTO + users (id, name, email) + VALUES + (uuid(), ?, ?) + RETURNING * + """.trimIndent(), + parameters = listOf("steven", "steven@journeyapps.com"), + ) {} + } + // The exception messages differ slightly between drivers + assertTrue { exception.message!!.contains("write a readonly database") } + } + + @Test + fun basicReadTransaction() = + runTest { + val count = + database.readTransaction { it -> + it.get("SELECT COUNT(*) from users") { it.getLong(0)!! } + } + assertEquals(expected = 0, actual = count) + } } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt index ad911963..2bf2e4bb 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt @@ -21,6 +21,7 @@ import com.powersync.testutils.MockSyncService import com.powersync.testutils.UserRow import com.powersync.testutils.cleanup import com.powersync.testutils.factory +import com.powersync.testutils.generatePrintLogWriter import com.powersync.testutils.waitFor import com.powersync.utils.JsonUtil import dev.mokkery.answering.returns @@ -53,7 +54,7 @@ class SyncIntegrationTest { Logger( TestConfig( minSeverity = Severity.Debug, - logWriterList = listOf(logWriter), + logWriterList = listOf(logWriter, generatePrintLogWriter()), ), ) private lateinit var database: PowerSyncDatabaseImpl diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index 249e7bd8..e10cb099 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -1,7 +1,23 @@ package com.powersync.testutils +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Severity import com.powersync.DatabaseDriverFactory expect val factory: DatabaseDriverFactory expect fun cleanup(path: String) + +expect fun getTempDir(): String? + +fun generatePrintLogWriter() = + object : LogWriter() { + override fun log( + severity: Severity, + message: String, + tag: String, + throwable: Throwable?, + ) { + println("[$severity:$tag] - $message") + } + } diff --git a/persistence/src/jvmMain/kotlin/com/powersync/persistence/driver/JdbcPreparedStatement.kt b/core/src/commonJava/kotlin/com/powersync/db/JdbcPreparedStatement.kt similarity index 97% rename from persistence/src/jvmMain/kotlin/com/powersync/persistence/driver/JdbcPreparedStatement.kt rename to core/src/commonJava/kotlin/com/powersync/db/JdbcPreparedStatement.kt index 796e5fa9..c3c98dfd 100644 --- a/persistence/src/jvmMain/kotlin/com/powersync/persistence/driver/JdbcPreparedStatement.kt +++ b/core/src/commonJava/kotlin/com/powersync/db/JdbcPreparedStatement.kt @@ -1,8 +1,9 @@ -package com.powersync.persistence.driver +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 @@ -207,8 +208,6 @@ internal class JdbcCursor( 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) diff --git a/core/src/jvmMain/kotlin/com/powersync/PSJdbcSqliteDriver.kt b/core/src/commonJava/kotlin/com/powersync/db/JdbcSqliteDriver.kt similarity index 64% rename from core/src/jvmMain/kotlin/com/powersync/PSJdbcSqliteDriver.kt rename to core/src/commonJava/kotlin/com/powersync/db/JdbcSqliteDriver.kt index ab3ec258..fc4d76b0 100644 --- a/core/src/jvmMain/kotlin/com/powersync/PSJdbcSqliteDriver.kt +++ b/core/src/commonJava/kotlin/com/powersync/db/JdbcSqliteDriver.kt @@ -1,4 +1,4 @@ -package com.powersync +package com.powersync.db import app.cash.sqldelight.Query import app.cash.sqldelight.Transacter @@ -8,73 +8,48 @@ 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 com.powersync.persistence.driver.JdbcPreparedStatement import org.sqlite.SQLiteConnection -import java.nio.file.Path import java.sql.DriverManager import java.sql.PreparedStatement import java.util.Properties -import kotlin.io.path.absolutePathString @Suppress("SqlNoDataSourceInspection", "SqlSourceToSinkFlow") -internal class PSJdbcSqliteDriver( +internal class JdbcSqliteDriver( url: String, properties: Properties = Properties(), ) : SqlDriver { - private val listeners = linkedMapOf>() + val connection: SQLiteConnection = + DriverManager.getConnection(url, properties) as SQLiteConnection + + private var transaction: Transaction? = null override fun addListener( vararg queryKeys: String, listener: Query.Listener, ) { - synchronized(listeners) { - queryKeys.forEach { - listeners.getOrPut(it, { linkedSetOf() }).add(listener) - } - } + // No Op, we don't currently use this } override fun removeListener( vararg queryKeys: String, listener: Query.Listener, ) { - synchronized(listeners) { - queryKeys.forEach { - listeners[it]?.remove(listener) - } - } + // No Op, we don't currently use this } override fun notifyListeners(vararg queryKeys: String) { - val listenersToNotify = linkedSetOf() - synchronized(listeners) { - queryKeys.forEach { listeners[it]?.let(listenersToNotify::addAll) } - } - listenersToNotify.forEach(Query.Listener::queryResultsChanged) + // No Op, we don't currently use this } - val connection: SQLiteConnection = DriverManager.getConnection(url, properties) as SQLiteConnection - - private var transaction: Transaction? = null - - private inner class Transaction( - override val enclosingTransaction: Transaction?, - ) : Transacter.Transaction() { - init { - connection.prepareStatement("BEGIN TRANSACTION").use(PreparedStatement::execute) - } + fun setVersion(version: Long) { + execute(null, "PRAGMA user_version = $version", 0, null).value + } - 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 + 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 { @@ -118,34 +93,50 @@ internal class PSJdbcSqliteDriver( stmt.executeQuery(mapper) } - internal fun loadExtensions(vararg extensions: Pair) { + 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.absolutePathString()) + statement.setString(1, path) statement.setString(2, entryPoint) statement.execute() } - check(executed) { "load_extension(\"${path.absolutePathString()}\", \"${entryPoint}\") failed" } + check(executed) { "load_extension(\"${path}\", \"${entryPoint}\") failed" } } connection.database.enable_load_extension(false) } - internal fun enableWriteAheadLogging() { - val executed = connection.prepareStatement("PRAGMA journal_mode=WAL;").execute() - check(executed) { "journal_mode=WAL failed" } + 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 PSJdbcSqliteDriver( - url: String, - properties: Properties = Properties(), +internal fun migrateDriver( + driver: JdbcSqliteDriver, schema: SqlSchema>, migrateEmptySchema: Boolean = false, vararg callbacks: AfterVersion, -): PSJdbcSqliteDriver { - val driver = PSJdbcSqliteDriver(url, properties) +) { val version = driver.getVersion() if (version == 0L && !migrateEmptySchema) { @@ -155,17 +146,4 @@ internal fun PSJdbcSqliteDriver( schema.migrate(driver, version, schema.version, *callbacks).value driver.setVersion(schema.version) } - - return driver -} - -private fun PSJdbcSqliteDriver.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 -} - -private fun PSJdbcSqliteDriver.setVersion(version: Long) { - execute(null, "PRAGMA user_version = $version", 0, null).value } diff --git a/core/src/commonJava/kotlin/com/powersync/db/WalProperties.kt b/core/src/commonJava/kotlin/com/powersync/db/WalProperties.kt new file mode 100644 index 00000000..5fa9a082 --- /dev/null +++ b/core/src/commonJava/kotlin/com/powersync/db/WalProperties.kt @@ -0,0 +1,18 @@ +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 ab469ff0..2d781f9e 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -7,5 +7,7 @@ public expect class DatabaseDriverFactory { internal fun createDriver( scope: CoroutineScope, dbFilename: String, + dbDirectory: String?, + readOnly: Boolean = false, ): PsSqlDriver } diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 71d2de99..fadbe369 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -19,6 +19,12 @@ import kotlin.coroutines.cancellation.CancellationException * 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. diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt index c3552a06..1206bfe2 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt @@ -22,6 +22,11 @@ public fun PowerSyncDatabase( dbFilename: String = DEFAULT_DB_FILENAME, scope: CoroutineScope = GlobalScope, logger: Logger? = null, + /** + * Optional database file directory path. + * This parameter is ignored for iOS. + */ + dbDirectory: String? = null, ): PowerSyncDatabase { val generatedLogger: Logger = generateLogger(logger) @@ -31,5 +36,6 @@ public fun PowerSyncDatabase( dbFilename = dbFilename, scope = scope, logger = generatedLogger, + dbDirectory = dbDirectory, ) } diff --git a/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt b/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt index 77c6f658..2c367e2b 100644 --- a/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt +++ b/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt @@ -1,16 +1,23 @@ 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.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow internal class PsSqlDriver( private val driver: SqlDriver, - private val scope: CoroutineScope, -) : SqlDriver by driver { +) : SqlDriver by driver, + ConnectionContext { // MutableSharedFlow to emit batched table updates private val tableUpdatesFlow = MutableSharedFlow>(replay = 0) @@ -32,6 +39,79 @@ internal class PsSqlDriver( .asSharedFlow() suspend fun fireTableUpdates() { - tableUpdatesFlow.emit(pendingUpdates.toSetAndClear()) + 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/ActiveInstanceStore.kt b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt index fba11bd1..1ba3faed 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/ActiveInstanceStore.kt @@ -26,6 +26,7 @@ internal class ActiveDatabaseGroup( ) { internal var refCount = 0 // Guarded by companion object internal val syncMutex = Mutex() + internal val writeLockMutex = Mutex() fun removeUsage() { collection.synchronize { diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index a0e06bf1..3bd1d3ba 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -3,7 +3,6 @@ package com.powersync.db import co.touchlab.kermit.Logger import com.powersync.DatabaseDriverFactory import com.powersync.PowerSyncDatabase -import com.powersync.PsSqlDriver import com.powersync.bucket.BucketPriority import com.powersync.bucket.BucketStorage import com.powersync.bucket.BucketStorageImpl @@ -14,7 +13,6 @@ import com.powersync.db.crud.CrudRow import com.powersync.db.crud.CrudTransaction import com.powersync.db.internal.InternalDatabaseImpl import com.powersync.db.internal.InternalTable -import com.powersync.db.internal.ThrowableTransactionCallback import com.powersync.db.schema.Schema import com.powersync.sync.PriorityStatusEntry import com.powersync.sync.SyncStatus @@ -56,8 +54,8 @@ internal class PowerSyncDatabaseImpl( val scope: CoroutineScope, val factory: DatabaseDriverFactory, private val dbFilename: String, + private val dbDirectory: String? = null, val logger: Logger = Logger, - driver: PsSqlDriver = factory.createDriver(scope, dbFilename), ) : PowerSyncDatabase { companion object { internal val streamConflictMessage = @@ -70,14 +68,24 @@ internal class PowerSyncDatabaseImpl( """.trimIndent() } - override val identifier = dbFilename + override val identifier = dbDirectory + dbFilename + + 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, + ) - private val internalDb = InternalDatabaseImpl(driver, scope) internal val bucketStorage: BucketStorage = BucketStorageImpl(internalDb, logger) - private val resource: ActiveDatabaseResource - private val clearResourceWhenDisposed: Any - var closed = false + override var closed = false /** * The current sync status. @@ -89,17 +97,22 @@ internal class PowerSyncDatabaseImpl( private var syncJob: Job? = null private var uploadJob: Job? = null - init { - val res = ActiveDatabaseGroup.referenceDatabase(logger, identifier) - resource = res.first - clearResourceWhenDisposed = res.second + // This is set in the init + private lateinit var powerSyncVersion: String + init { runBlocking { - val sqliteVersion = internalDb.queries.sqliteVersion().executeAsOne() + val sqliteVersion = internalDb.get("SELECT sqlite_version()") { it.getString(0)!! } logger.d { "SQLiteVersion: $sqliteVersion" } - checkVersion() + powerSyncVersion = + internalDb.get("SELECT powersync_rs_version()") { it.getString(0)!! } + checkVersion(powerSyncVersion) logger.d { "PowerSyncVersion: ${getPowerSyncVersion()}" } - internalDb.queries.powersyncInit() + + internalDb.writeTransaction { tx -> + tx.getOptional("SELECT powersync_init()") {} + } + applySchema() updateHasSynced() } @@ -108,8 +121,11 @@ internal class PowerSyncDatabaseImpl( private suspend fun applySchema() { val schemaJson = JsonUtil.json.encodeToString(schema) - internalDb.writeTransaction { - internalDb.queries.replaceSchema(schemaJson).executeAsOne() + internalDb.writeTransaction { tx -> + tx.getOptional( + "SELECT powersync_replace_schema(?);", + listOf(schemaJson), + ) {} } } @@ -214,12 +230,15 @@ internal class PowerSyncDatabaseImpl( } val entries = - internalDb.queries.getCrudEntries((limit + 1).toLong()).executeAsList().map { + internalDb.getAll( + "SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT ?", + listOf(limit.toLong()), + ) { CrudEntry.fromRow( CrudRow( - id = it.id.toString(), - data = it.data_!!, - txId = it.tx_id?.toInt(), + id = it.getString("id"), + data = it.getString("data"), + txId = it.getLongOptional("tx_id")?.toInt(), ), ) } @@ -249,12 +268,12 @@ internal class PowerSyncDatabaseImpl( if (txId == null) { listOf(entry) } else { - internalDb.queries.getCrudEntryByTxId(txId.toLong()).executeAsList().map { + transaction.getAll("SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT 1") { CrudEntry.fromRow( CrudRow( - id = it.id.toString(), - data = it.data_!!, - txId = it.tx_id?.toInt(), + id = it.getString("id"), + data = it.getString("data"), + txId = it.getLongOptional("tx_id")?.toInt(), ), ) } @@ -271,7 +290,8 @@ internal class PowerSyncDatabaseImpl( } } - override suspend fun getPowerSyncVersion(): String = internalDb.queries.powerSyncVersion().executeAsOne() + // The initialization sets powerSyncVersion. We currently run the init as a blocking operation + override suspend fun getPowerSyncVersion(): String = powerSyncVersion override suspend fun get( sql: String, @@ -298,8 +318,12 @@ internal class PowerSyncDatabaseImpl( mapper: (SqlCursor) -> RowType, ): Flow> = internalDb.watch(sql, parameters, throttleMs, mapper) + override suspend fun readLock(callback: ThrowableLockCallback): R = internalDb.readLock(callback) + override suspend fun readTransaction(callback: ThrowableTransactionCallback): R = internalDb.writeTransaction(callback) + override suspend fun writeLock(callback: ThrowableLockCallback): R = internalDb.writeLock(callback) + override suspend fun writeTransaction(callback: ThrowableTransactionCallback): R = internalDb.writeTransaction(callback) override suspend fun execute( @@ -312,7 +336,10 @@ internal class PowerSyncDatabaseImpl( writeCheckpoint: String?, ) { internalDb.writeTransaction { transaction -> - internalDb.queries.deleteEntriesWithIdLessThan(lastTransactionId.toLong()) + transaction.execute( + "DELETE FROM ps_crud WHERE id <= ?", + listOf(lastTransactionId.toLong()), + ) if (writeCheckpoint != null && !bucketStorage.hasCrud(transaction)) { transaction.execute( @@ -354,8 +381,8 @@ internal class PowerSyncDatabaseImpl( override suspend fun disconnectAndClear(clearLocal: Boolean) { disconnect() - internalDb.writeTransaction { - internalDb.queries.powersyncClear(if (clearLocal) "1" else "0").executeAsOne() + internalDb.writeTransaction { tx -> + tx.getOptional("SELECT powersync_clear(?)", listOf(if (clearLocal) "1" else "0")) {} } currentStatus.update(lastSyncedAt = null, hasSynced = false) } @@ -437,28 +464,21 @@ internal class PowerSyncDatabaseImpl( /** * Check that a supported version of the powersync extension is loaded. */ - private suspend fun checkVersion() { - val version: String = - try { - getPowerSyncVersion() - } catch (e: Exception) { - throw Exception("The powersync extension is not loaded correctly. Details: $e") - } - + private suspend fun checkVersion(powerSyncVersion: String) { // Parse version val versionInts: List = try { - version + powerSyncVersion .split(Regex("[./]")) .take(3) .map { it.toInt() } } catch (e: Exception) { - throw Exception("Unsupported powersync extension version. Need ^0.2.0, got: $version. Details: $e") + throw Exception("Unsupported powersync extension version. Need ^0.2.0, got: $powerSyncVersion. Details: $e") } // Validate ^0.2.0 if (versionInts[0] != 0 || versionInts[1] < 2 || versionInts[2] < 0) { - throw Exception("Unsupported powersync extension version. Need ^0.2.0, got: $version") + throw Exception("Unsupported powersync extension version. Need ^0.2.0, got: $powerSyncVersion") } } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt index ba1a0063..3ab9a7bc 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt @@ -1,10 +1,21 @@ package com.powersync.db import com.powersync.PowerSyncException -import com.powersync.db.internal.ThrowableTransactionCallback +import com.powersync.db.internal.ConnectionContext +import com.powersync.db.internal.PowerSyncTransaction import kotlinx.coroutines.flow.Flow import kotlin.coroutines.cancellation.CancellationException +public fun interface ThrowableTransactionCallback { + @Throws(PowerSyncException::class, kotlinx.coroutines.CancellationException::class) + public fun execute(transaction: PowerSyncTransaction): R +} + +public fun interface ThrowableLockCallback { + @Throws(PowerSyncException::class, kotlinx.coroutines.CancellationException::class) + public fun execute(context: ConnectionContext): R +} + public interface Queries { /** * Execute a write query (INSERT, UPDATE, DELETE) @@ -61,9 +72,53 @@ public interface Queries { mapper: (SqlCursor) -> RowType, ): Flow> + /** + * Takes a global lock, without starting a transaction. + * + * This takes a global lock - only one write transaction can execute against + * the database at a time. This applies even when constructing separate + * database instances for the same database file. + * + * Locks for separate database instances on the same database file + * may be held concurrently. + * + * In most cases, [writeTransaction] should be used instead. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun writeLock(callback: ThrowableLockCallback): R + + /** + * Open a read-write transaction. + * + * This takes a global lock - only one write transaction can execute against + * the database at a time. This applies even when constructing separate + * database instances for the same database file. + * + * Statements within the transaction must be done on the provided + * [PowerSyncTransaction] - attempting statements on the database + * instance will error cause a dead-lock. + */ @Throws(PowerSyncException::class, CancellationException::class) public suspend fun writeTransaction(callback: ThrowableTransactionCallback): R + /** + * Takes a read lock, without starting a transaction. + * + * The lock only applies to a single SQLite connection, and multiple + * connections may hold read locks at the same time. + * + * In most cases, [readTransaction] should be used instead. + */ + @Throws(PowerSyncException::class, CancellationException::class) + public suspend fun readLock(callback: ThrowableLockCallback): R + + /** + * Open a read-only transaction. + * + * Statements within the transaction must be done on the provided + * [PowerSyncTransaction] - executing statements on the database level + * will be executed on separate connections. + */ @Throws(PowerSyncException::class, CancellationException::class) public suspend fun readTransaction(callback: ThrowableTransactionCallback): R } diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt new file mode 100644 index 00000000..1bd5b6d4 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt @@ -0,0 +1,33 @@ +package com.powersync.db.internal + +import com.powersync.PowerSyncException +import com.powersync.db.SqlCursor + +public interface ConnectionContext { + @Throws(PowerSyncException::class) + public fun execute( + sql: String, + parameters: List? = listOf(), + ): Long + + @Throws(PowerSyncException::class) + public fun getOptional( + sql: String, + parameters: List? = listOf(), + mapper: (SqlCursor) -> RowType, + ): RowType? + + @Throws(PowerSyncException::class) + public fun getAll( + sql: String, + parameters: List? = listOf(), + mapper: (SqlCursor) -> RowType, + ): List + + @Throws(PowerSyncException::class) + public fun get( + sql: String, + parameters: List? = listOf(), + mapper: (SqlCursor) -> RowType, + ): RowType +} diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt new file mode 100644 index 00000000..f5c800fc --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt @@ -0,0 +1,65 @@ +package com.powersync.db.internal + +import com.powersync.PowerSyncException +import com.powersync.PsSqlDriver +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch + +internal class ConnectionPool( + factory: () -> PsSqlDriver, + size: Int = 5, + private val scope: CoroutineScope, +) { + private val available = Channel>>() + private val connections: List = + List(size) { + scope.launch { + val driver = TransactorDriver(factory()) + try { + while (true) { + val done = CompletableDeferred() + try { + available.send(driver to done) + } catch (_: ClosedSendChannelException) { + break // Pool closed + } + + done.await() + } + } finally { + driver.driver.close() + } + } + } + + suspend fun withConnection(action: suspend (connection: TransactorDriver) -> R): R { + val (connection, done) = + try { + available.receive() + } catch (e: PoolClosedException) { + throw PowerSyncException( + message = "Cannot process connection pool request", + cause = e, + ) + } + + try { + return action(connection) + } finally { + done.complete(Unit) + } + } + + suspend fun close() { + available.cancel(PoolClosedException) + connections.joinAll() + } +} + +internal object PoolClosedException : CancellationException("Pool is closed") diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabase.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabase.kt index 92362904..40a42338 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabase.kt @@ -1,16 +1,10 @@ package com.powersync.db.internal -import app.cash.sqldelight.db.Closeable -import com.persistence.PowersyncQueries import com.powersync.db.Queries -import com.powersync.persistence.PsDatabase import kotlinx.coroutines.flow.SharedFlow -internal interface InternalDatabase : - Queries, - Closeable { - val transactor: PsDatabase - val queries: PowersyncQueries - +internal interface InternalDatabase : Queries { fun updatesOnTables(): SharedFlow> + + suspend fun close(): Unit } 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 4d37cecc..5df03a96 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -1,17 +1,15 @@ package com.powersync.db.internal -import app.cash.sqldelight.ExecutableQuery -import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.db.SqlPreparedStatement -import com.persistence.PowersyncQueries +import com.powersync.DatabaseDriverFactory import com.powersync.PowerSyncException -import com.powersync.PsSqlDriver import com.powersync.db.SqlCursor +import com.powersync.db.ThrowableLockCallback +import com.powersync.db.ThrowableTransactionCallback import com.powersync.db.runWrapped -import com.powersync.persistence.PsDatabase +import com.powersync.db.runWrappedSuspending import com.powersync.utils.JsonUtil import com.powersync.utils.throttle -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -28,41 +26,33 @@ import kotlinx.serialization.encodeToString @OptIn(FlowPreview::class) internal class InternalDatabaseImpl( - private val driver: PsSqlDriver, + private val factory: DatabaseDriverFactory, private val scope: CoroutineScope, + private val dbFilename: String, + private val dbDirectory: String?, + private val writeLockMutex: Mutex, ) : InternalDatabase { - override val transactor: PsDatabase = PsDatabase(driver) - override val queries: PowersyncQueries = transactor.powersyncQueries + private val writeConnection = + TransactorDriver( + factory.createDriver( + scope = scope, + dbFilename = dbFilename, + dbDirectory = dbDirectory, + ), + ) + + private val readPool = + ConnectionPool(factory = { + factory.createDriver( + scope = scope, + dbFilename = dbFilename, + dbDirectory = dbDirectory, + readOnly = 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 val writeLock = Mutex() - - private val transaction = - object : PowerSyncTransaction { - override fun execute( - sql: String, - parameters: List?, - ): Long = this@InternalDatabaseImpl.executeSync(sql, parameters ?: emptyList()) - - override fun get( - sql: String, - parameters: List?, - mapper: (SqlCursor) -> RowType, - ): RowType = this@InternalDatabaseImpl.getSync(sql, parameters ?: emptyList(), mapper) - - override fun getAll( - sql: String, - parameters: List?, - mapper: (SqlCursor) -> RowType, - ): List = this@InternalDatabaseImpl.getAllSync(sql, parameters ?: emptyList(), mapper) - - override fun getOptional( - sql: String, - parameters: List?, - mapper: (SqlCursor) -> RowType, - ): RowType? = this@InternalDatabaseImpl.getOptionalSync(sql, parameters ?: emptyList(), mapper) - } companion object { const val DEFAULT_WATCH_THROTTLE_MS = 30L @@ -72,90 +62,27 @@ internal class InternalDatabaseImpl( sql: String, parameters: List?, ): Long = - writeLock.withLock { - withContext(dbContext) { - executeSync(sql, parameters) - }.also { - driver.fireTableUpdates() - } + writeLock { context -> + context.execute(sql, parameters) } - private fun executeSync( - 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 suspend fun get( sql: String, parameters: List?, mapper: (SqlCursor) -> RowType, - ): RowType = withContext(dbContext) { getSync(sql, parameters, mapper) } - - private fun getSync( - 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" } - } + ): RowType = readLock { connection -> connection.get(sql, parameters, mapper) } override suspend fun getAll( sql: String, parameters: List?, mapper: (SqlCursor) -> RowType, - ): List = withContext(dbContext) { getAllSync(sql, parameters, mapper) } - - private fun getAllSync( - sql: String, - parameters: List?, - mapper: (SqlCursor) -> RowType, - ): List = - this - .createQuery( - query = sql, - parameters = parameters?.size ?: 0, - binders = getBindersFromParams(parameters), - mapper = mapper, - ).executeAsList() + ): List = readLock { connection -> connection.getAll(sql, parameters, mapper) } override suspend fun getOptional( sql: String, parameters: List?, mapper: (SqlCursor) -> RowType, - ): RowType? = withContext(dbContext) { getOptionalSync(sql, parameters, mapper) } - - private fun getOptionalSync( - sql: String, - parameters: List?, - mapper: (SqlCursor) -> RowType, - ): RowType? = - this - .createQuery( - query = sql, - parameters = parameters?.size ?: 0, - binders = getBindersFromParams(parameters), - mapper = mapper, - ).executeAsOneOrNull() + ): RowType? = readLock { connection -> connection.getOptional(sql, parameters, mapper) } override fun watch( sql: String, @@ -192,53 +119,85 @@ internal class InternalDatabaseImpl( } } - 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) + /** + * Creates a read lock while providing an internal transactor for transactions + */ + private suspend fun internalReadLock(callback: (TransactorDriver) -> R): R = + withContext(dbContext) { + runWrappedSuspending { + readPool.withConnection { + catchSwiftExceptions { + callback(it) + } } + } + } + + override suspend fun readLock(callback: ThrowableLockCallback): R = + internalReadLock { + callback.execute(it.driver) } override suspend fun readTransaction(callback: ThrowableTransactionCallback): R = + internalReadLock { + it.transactor.transactionWithResult(noEnclosing = true) { + catchSwiftExceptions { + callback.execute( + PowerSyncTransactionImpl( + it.driver, + ), + ) + } + } + } + + private suspend fun internalWriteLock(callback: (TransactorDriver) -> R): R = withContext(dbContext) { - transactor.transactionWithResult(noEnclosing = true) { + writeLockMutex.withLock { runWrapped { - val result = callback.execute(transaction) - if (result is PowerSyncException) { - throw result + catchSwiftExceptions { + callback(writeConnection) } - result + }.also { + // Trigger watched queries + // Fire updates inside the write lock + writeConnection.driver.fireTableUpdates() } } } + override suspend fun writeLock(callback: ThrowableLockCallback): R = + internalWriteLock { + callback.execute(it.driver) + } + override suspend fun writeTransaction(callback: ThrowableTransactionCallback): R = - writeLock.withLock { - withContext(dbContext) { - transactor.transactionWithResult(noEnclosing = true) { - runWrapped { - val result = callback.execute(transaction) - if (result is PowerSyncException) { - throw result - } - result - } + internalWriteLock { + it.transactor.transactionWithResult(noEnclosing = true) { + // Need to catch Swift exceptions here for Rollback + catchSwiftExceptions { + callback.execute( + PowerSyncTransactionImpl( + it.driver, + ), + ) } - }.also { - // Trigger watched queries - // Fire updates inside the write lock - driver.fireTableUpdates() } } // Register callback for table updates on a specific table - override fun updatesOnTables(): SharedFlow> = driver.updatesOnTables() + override fun updatesOnTables(): SharedFlow> = writeConnection.driver.updatesOnTables() + + // Unfortunately Errors can't be thrown from Swift SDK callbacks. + // These are currently returned and should be thrown here. + private fun catchSwiftExceptions(action: () -> R): R { + val result = action() + + if (result is PowerSyncException) { + throw result + } + return result + } private suspend fun getSourceTables( sql: String, @@ -276,8 +235,11 @@ internal class InternalDatabaseImpl( return tableRows.toSet() } - override fun close() { - runWrapped { this.driver.close() } + override suspend fun close() { + runWrappedSuspending { + writeConnection.driver.close() + readPool.close() + } } internal data class ExplainQueryResult( @@ -310,11 +272,3 @@ internal fun getBindersFromParams(parameters: List?): (SqlPreparedStatemen } } } - -/** - * Kotlin allows SAM (Single Abstract Method) interfaces to be treated like lambda expressions. - */ -public fun interface ThrowableTransactionCallback { - @Throws(PowerSyncException::class, CancellationException::class) - public fun execute(transaction: PowerSyncTransaction): R -} 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 3902b5f8..74b89eb7 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt @@ -1,34 +1,8 @@ package com.powersync.db.internal -import com.powersync.PowerSyncException -import com.powersync.db.SqlCursor -import kotlin.coroutines.cancellation.CancellationException +public interface PowerSyncTransaction : ConnectionContext -public interface PowerSyncTransaction { - @Throws(PowerSyncException::class, CancellationException::class) - public fun execute( - sql: String, - parameters: List? = listOf(), - ): Long - - @Throws(PowerSyncException::class, CancellationException::class) - public fun getOptional( - sql: String, - parameters: List? = listOf(), - mapper: (SqlCursor) -> RowType, - ): RowType? - - @Throws(PowerSyncException::class, CancellationException::class) - public fun getAll( - sql: String, - parameters: List? = listOf(), - mapper: (SqlCursor) -> RowType, - ): List - - @Throws(PowerSyncException::class, CancellationException::class) - public fun get( - sql: String, - parameters: List? = listOf(), - mapper: (SqlCursor) -> RowType, - ): RowType -} +internal class PowerSyncTransactionImpl( + context: ConnectionContext, +) : PowerSyncTransaction, + ConnectionContext by context diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/TransactorDriver.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/TransactorDriver.kt new file mode 100644 index 00000000..ee6d1efd --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/TransactorDriver.kt @@ -0,0 +1,13 @@ +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/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt b/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt index a12f7974..e6041c5b 100644 --- a/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt +++ b/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt @@ -4,53 +4,38 @@ import co.touchlab.sqliter.DatabaseConfiguration import co.touchlab.sqliter.DatabaseConfiguration.Logging import co.touchlab.sqliter.DatabaseConnection 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 com.powersync.db.internal.InternalSchema import com.powersync.persistence.driver.NativeSqliteDriver import com.powersync.persistence.driver.wrapConnection -import com.powersync.sqlite.core.init_powersync_sqlite_extension -import com.powersync.sqlite.core.sqlite3_commit_hook -import com.powersync.sqlite.core.sqlite3_rollback_hook -import com.powersync.sqlite.core.sqlite3_update_hook +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.CPointerVar import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.MemScope import kotlinx.cinterop.StableRef +import kotlinx.cinterop.alloc import kotlinx.cinterop.asStableRef +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 platform.Foundation.NSBundle @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @OptIn(ExperimentalForeignApi::class) public actual class DatabaseDriverFactory { - private var driver: PsSqlDriver? = null - - init { - init_powersync_sqlite_extension() - } - - @Suppress("unused", "UNUSED_PARAMETER") - private fun updateTableHook( - opType: Int, - databaseName: String, - tableName: String, - rowId: Long, - ) { - driver?.updateTable(tableName) - } - - private fun onTransactionCommit(success: Boolean) { - driver?.also { driver -> - // Only clear updates on rollback - // We manually fire updates when a transaction ended - if (!success) { - driver.clearTableUpdates() - } - } - } - internal actual fun createDriver( scope: CoroutineScope, dbFilename: String, + dbDirectory: String?, + readOnly: Boolean, ): PsSqlDriver { val schema = InternalSchema val sqlLogger = @@ -71,21 +56,31 @@ public actual class DatabaseDriverFactory { override fun vWrite(message: String) {} } - this.driver = + // 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( - scope = scope, driver = NativeSqliteDriver( configuration = DatabaseConfiguration( name = dbFilename, version = schema.version.toInt(), - create = { connection -> wrapConnection(connection) { schema.create(it) } }, + create = { connection -> + wrapConnection(connection) { + schema.create( + it, + ) + } + }, loggingConfig = Logging(logger = sqlLogger), lifecycleConfig = DatabaseConfiguration.Lifecycle( onCreateConnection = { connection -> - setupSqliteBinding(connection) + setupSqliteBinding(connection, deferredDriver) wrapConnection(connection) { driver -> schema.create(driver) } @@ -97,56 +92,115 @@ public actual class DatabaseDriverFactory { ), ), ) - return this.driver as PsSqlDriver + + // 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") + } + + deferredDriver.setDriver(driver) + + return driver } - private fun setupSqliteBinding(connection: DatabaseConnection) { + private fun setupSqliteBinding( + connection: DatabaseConnection, + driver: DeferredDriver, + ) { val ptr = connection.getDbPointer().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) { + 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) + if (result != SqliteErrorType.SQLITE_OK.code) { + val errorMessage = errMsg.value?.toKString() ?: "Unknown error" + throw PowerSyncException( + "Could not load the PowerSync SQLite core extension", + cause = + Exception( + "Calling sqlite3_load_extension failed with error: $errorMessage", + ), + ) + } + + val driverRef = StableRef.create(driver) - // Register the update hook sqlite3_update_hook( ptr, staticCFunction { usrPtr, updateType, dbName, tableName, rowId -> - val callback = - usrPtr!! - .asStableRef<(Int, String, String, Long) -> Unit>() - .get() - callback( - updateType, - dbName!!.toKString(), - tableName!!.toKString(), - rowId, - ) + usrPtr!! + .asStableRef() + .get() + .updateTableHook(tableName!!.toKString()) }, - StableRef.create(::updateTableHook).asCPointer(), + driverRef.asCPointer(), ) - // Register transaction hooks sqlite3_commit_hook( ptr, staticCFunction { usrPtr -> - val callback = usrPtr!!.asStableRef<(Boolean) -> Unit>().get() - callback(true) + usrPtr!!.asStableRef().get().onTransactionCommit(true) 0 }, - StableRef.create(::onTransactionCommit).asCPointer(), + driverRef.asCPointer(), ) + sqlite3_rollback_hook( ptr, staticCFunction { usrPtr -> - val callback = usrPtr!!.asStableRef<(Boolean) -> Unit>().get() - callback(false) + usrPtr!!.asStableRef().get().onTransactionCommit(false) }, - StableRef.create(::onTransactionCommit).asCPointer(), + driverRef.asCPointer(), ) } private fun deregisterSqliteBinding(connection: DatabaseConnection) { - val ptr = connection.getDbPointer().getPointer(MemScope()) + val basePtr = connection.getDbPointer().getPointer(MemScope()) + sqlite3_update_hook( - ptr, + basePtr, null, null, ) } + + 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."), + ) + + // Construct full path to the shared library inside the bundle + bundlePath.let { "$it/powersync-sqlite-core" } + } + } } diff --git a/core/src/iosMain/kotlin/com/powersync/DeferredDriver.kt b/core/src/iosMain/kotlin/com/powersync/DeferredDriver.kt new file mode 100644 index 00000000..f4c0b5fc --- /dev/null +++ b/core/src/iosMain/kotlin/com/powersync/DeferredDriver.kt @@ -0,0 +1,27 @@ +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/iosSimulatorArm64Test/kotlin/com/powersync/DatabaseDriverFactoryTest.kt b/core/src/iosSimulatorArm64Test/kotlin/com/powersync/DatabaseDriverFactoryTest.kt new file mode 100644 index 00000000..c013cd83 --- /dev/null +++ b/core/src/iosSimulatorArm64Test/kotlin/com/powersync/DatabaseDriverFactoryTest.kt @@ -0,0 +1,10 @@ +package com.powersync + +import kotlin.test.Test + +class DatabaseDriverFactoryTest { + @Test + fun findsPowerSyncFramework() { + DatabaseDriverFactory.powerSyncExtensionPath + } +} diff --git a/core/src/iosSimulatorArm64Test/kotlin/com/powersync/testutils/TestUtils.iosSimulatorArm64.kt b/core/src/iosSimulatorArm64Test/kotlin/com/powersync/testutils/TestUtils.iosSimulatorArm64.kt index 10f976bc..1a4e93ea 100644 --- a/core/src/iosSimulatorArm64Test/kotlin/com/powersync/testutils/TestUtils.iosSimulatorArm64.kt +++ b/core/src/iosSimulatorArm64Test/kotlin/com/powersync/testutils/TestUtils.iosSimulatorArm64.kt @@ -13,3 +13,11 @@ actual fun cleanup(path: String) { SystemFileSystem.delete(resolved) } } + +/** + * We could use SystemTemporaryDirectory here in future, but we return null here + * to skip tests which rely on a temporary directory for iOS. + * The reason for skipping these tests is that the SQLiteR library does not currently + * support opening DB paths for custom directories. + */ +actual fun getTempDir(): String? = null diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index d99a0756..acb115d6 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -1,30 +1,42 @@ 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 java.nio.file.Path @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") public actual class DatabaseDriverFactory { internal actual fun createDriver( scope: CoroutineScope, dbFilename: String, + dbDirectory: String?, + readOnly: Boolean, ): PsSqlDriver { val schema = InternalSchema + val dbPath = + if (dbDirectory != null) { + "$dbDirectory/$dbFilename" + } else { + dbFilename + } + val driver = - PSJdbcSqliteDriver( - url = "jdbc:sqlite:$dbFilename", - schema = schema, + JdbcSqliteDriver( + url = "jdbc:sqlite:$dbPath", + properties = buildDefaultWalProperties(readOnly = readOnly), ) - // Generates SQLITE_BUSY errors -// driver.enableWriteAheadLogging() + + migrateDriver(driver, schema) + driver.loadExtensions( powersyncExtension to "sqlite3_powersync_init", ) - val mappedDriver = PsSqlDriver(scope = scope, driver = driver) + val mappedDriver = PsSqlDriver(driver = driver) driver.connection.database.addUpdateListener { _, _, table, _ -> mappedDriver.updateTable(table) @@ -45,6 +57,6 @@ public actual class DatabaseDriverFactory { } public companion object { - private val powersyncExtension: Path = extractLib("powersync") + private val powersyncExtension: String = extractLib("powersync").toString() } } diff --git a/core/src/jvmNative/cpp/sqlite_bindings.cpp b/core/src/jvmNative/cpp/sqlite_bindings.cpp deleted file mode 100644 index 8f4cc16d..00000000 --- a/core/src/jvmNative/cpp/sqlite_bindings.cpp +++ /dev/null @@ -1,91 +0,0 @@ -#include -#include -#include -#include - -typedef struct context { - JavaVM *javaVM; - jobject bindingsObj; - jclass bindingsClz; -} Context; -Context g_ctx; - -extern "C" { - -JNIEXPORT -jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { - JNIEnv *env; - memset(&g_ctx, 0, sizeof(g_ctx)); - g_ctx.javaVM = vm; - - if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) { - return JNI_ERR; // JNI version not supported. - } - - return JNI_VERSION_1_6; -} - -static void update_hook_callback(void *pData, int opCode, char const *pDbName, char const *pTableName, sqlite3_int64 iRow) { - // Get JNIEnv for the current thread - JNIEnv *env; - JavaVM *javaVM = g_ctx.javaVM; - javaVM->GetEnv((void **) &env, JNI_VERSION_1_6); - - if (g_ctx.bindingsClz) { - jmethodID updateId = env->GetMethodID( - g_ctx.bindingsClz, "onTableUpdate", "(Ljava/lang/String;)V"); - - jstring tableString = env->NewStringUTF(std::string(pTableName).c_str()); - env->CallVoidMethod(g_ctx.bindingsObj, updateId, tableString); - } -} - -static int commit_hook(void *pool) { - // Get JNIEnv for the current thread - JNIEnv *env; - JavaVM *javaVM = g_ctx.javaVM; - javaVM->GetEnv((void **) &env, JNI_VERSION_1_6); - - if (g_ctx.bindingsClz) { - jmethodID methodId = env->GetMethodID( - g_ctx.bindingsClz, "onTransactionCommit", "(Z)V"); - - env->CallVoidMethod(g_ctx.bindingsObj, methodId, JNI_TRUE); - } - - return 0; -} - -static void rollback_hook(void *pool) { - // Get JNIEnv for the current thread - JNIEnv *env; - JavaVM *javaVM = g_ctx.javaVM; - javaVM->GetEnv((void **) &env, JNI_VERSION_1_6); - - if (g_ctx.bindingsClz) { - jmethodID methodId = env->GetMethodID( - g_ctx.bindingsClz, "onTransactionCommit", "(Z)V"); - - env->CallVoidMethod(g_ctx.bindingsObj, methodId, JNI_FALSE); - } -} - -JNIEXPORT -int powersync_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) { - sqlite3_initialize(); - - sqlite3_update_hook(db, update_hook_callback, NULL); - sqlite3_commit_hook(db, commit_hook, NULL); - sqlite3_rollback_hook(db, rollback_hook, NULL); - - return SQLITE_OK; -} - -JNIEXPORT -void JNICALL Java_com_powersync_DatabaseDriverFactory_setupSqliteBinding(JNIEnv *env, jobject thiz) { - jclass clz = env->GetObjectClass(thiz); - g_ctx.bindingsClz = (jclass) env->NewGlobalRef(clz); - g_ctx.bindingsObj = env->NewGlobalRef(thiz); -} - -} diff --git a/core/src/jvmTest/kotlin/com/powersync/testutils/TestUtils.jvm.kt b/core/src/jvmTest/kotlin/com/powersync/testutils/TestUtils.jvm.kt index ec793bd1..3b53926a 100644 --- a/core/src/jvmTest/kotlin/com/powersync/testutils/TestUtils.jvm.kt +++ b/core/src/jvmTest/kotlin/com/powersync/testutils/TestUtils.jvm.kt @@ -9,3 +9,5 @@ actual val factory: DatabaseDriverFactory actual fun cleanup(path: String) { File(path).delete() } + +actual fun getTempDir(): String? = System.getProperty("java.io.tmpdir") diff --git a/core/src/nativeInterop/cinterop/powersync-sqlite-core.def b/core/src/nativeInterop/cinterop/powersync-sqlite-core.def deleted file mode 100644 index 0e5e6f3c..00000000 --- a/core/src/nativeInterop/cinterop/powersync-sqlite-core.def +++ /dev/null @@ -1,15 +0,0 @@ -package = com.powersync.sqlite.core -headers = sqlite3.h -headerFilter = sqlite3*.h -linkerOpts = -lsqlite3 - -noStringConversion = sqlite3_prepare_v2 sqlite3_prepare_v3 ---- -extern int sqlite3_powersync_init(sqlite3 *db, char **pzErrMsg, - const sqlite3_api_routines *pApi); - -static int init_powersync_sqlite_extension() { - int result = - sqlite3_auto_extension((void (*)(void)) &sqlite3_powersync_init); - return result; -} diff --git a/demos/android-supabase-todolist/settings.gradle.kts b/demos/android-supabase-todolist/settings.gradle.kts index d9e18e1b..015b43da 100644 --- a/demos/android-supabase-todolist/settings.gradle.kts +++ b/demos/android-supabase-todolist/settings.gradle.kts @@ -17,9 +17,6 @@ dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() - maven("https://jitpack.io") { - content { includeGroup("com.github.requery") } - } mavenCentral() } } @@ -27,20 +24,22 @@ dependencyResolutionManagement { rootProject.name = "PowersyncAndroidExample" include(":app") -val localProperties = Properties().apply { - try { - load(file("local.properties").reader()) - } catch (ignored: java.io.IOException) { - // ignore +val localProperties = + Properties().apply { + try { + load(file("local.properties").reader()) + } catch (ignored: java.io.IOException) { + // ignore + } } -} val useReleasedVersions = localProperties.getProperty("USE_RELEASED_POWERSYNC_VERSIONS") == "true" if (!useReleasedVersions) { includeBuild("../..") { dependencySubstitution { substitute(module("com.powersync:core")) - .using(project(":core")).because("we want to auto-wire up sample dependency") + .using(project(":core")) + .because("we want to auto-wire up sample dependency") substitute(module("com.powersync:connector-supabase")) .using(project(":connectors:supabase")) .because("we want to auto-wire up sample dependency") diff --git a/demos/hello-powersync/composeApp/build.gradle.kts b/demos/hello-powersync/composeApp/build.gradle.kts index 6cc4585a..e67048d0 100644 --- a/demos/hello-powersync/composeApp/build.gradle.kts +++ b/demos/hello-powersync/composeApp/build.gradle.kts @@ -29,6 +29,7 @@ kotlin { pod("powersync-sqlite-core") { linkOnly = true + version = "0.3.12" } framework { diff --git a/demos/hello-powersync/composeApp/composeApp.podspec b/demos/hello-powersync/composeApp/composeApp.podspec index eb06aa60..716d7345 100644 --- a/demos/hello-powersync/composeApp/composeApp.podspec +++ b/demos/hello-powersync/composeApp/composeApp.podspec @@ -9,7 +9,7 @@ Pod::Spec.new do |spec| spec.vendored_frameworks = 'build/cocoapods/framework/composeApp.framework' spec.libraries = 'c++' spec.ios.deployment_target = '15.2' - spec.dependency 'powersync-sqlite-core' + spec.dependency 'powersync-sqlite-core', '0.3.12' if !Dir.exist?('build/cocoapods/framework/composeApp.framework') || Dir.empty?('build/cocoapods/framework/composeApp.framework') raise " diff --git a/demos/hello-powersync/gradle/wrapper/gradle-wrapper.properties b/demos/hello-powersync/gradle/wrapper/gradle-wrapper.properties index b82aa23a..dec19f80 100644 --- a/demos/hello-powersync/gradle/wrapper/gradle-wrapper.properties +++ b/demos/hello-powersync/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ +#Sat Mar 15 13:10:11 SAST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/demos/hello-powersync/iosApp/Podfile.lock b/demos/hello-powersync/iosApp/Podfile.lock index 9ee6106f..0f707dfb 100644 --- a/demos/hello-powersync/iosApp/Podfile.lock +++ b/demos/hello-powersync/iosApp/Podfile.lock @@ -1,7 +1,7 @@ PODS: - composeApp (1.0.0): - - powersync-sqlite-core - - powersync-sqlite-core (0.2.1) + - powersync-sqlite-core (= 0.3.12) + - powersync-sqlite-core (0.3.12) DEPENDENCIES: - composeApp (from `../composeApp`) @@ -15,8 +15,8 @@ EXTERNAL SOURCES: :path: "../composeApp" SPEC CHECKSUMS: - composeApp: d8d73ede600d0ced841c96b458570e50cad2d030 - powersync-sqlite-core: 38ead13d8b21920cfbc79e9b3415b833574a506d + composeApp: 904d95008148b122d963aa082a29624b99d0f4e1 + powersync-sqlite-core: fcc32da5528fca9d50b185fcd777705c034e255b PODFILE CHECKSUM: 4680f51fbb293d1385fb2467ada435cc1f16ab3d diff --git a/demos/hello-powersync/settings.gradle.kts b/demos/hello-powersync/settings.gradle.kts index 271061d1..9064f80c 100644 --- a/demos/hello-powersync/settings.gradle.kts +++ b/demos/hello-powersync/settings.gradle.kts @@ -15,9 +15,6 @@ dependencyResolutionManagement { google() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") - maven("https://jitpack.io") { - content { includeGroup("com.github.requery") } - } } versionCatalogs { create("projectLibs") { @@ -33,11 +30,13 @@ include(":composeApp") includeBuild("../..") { dependencySubstitution { substitute(module("com.powersync:core")) - .using(project(":core")).because("we want to auto-wire up sample dependency") + .using(project(":core")) + .because("we want to auto-wire up sample dependency") substitute(module("com.powersync:connector-supabase")) .using(project(":connectors:supabase")) .because("we want to auto-wire up sample dependency") substitute(module("com.powersync:compose")) - .using(project(":compose")).because("we want to auto-wire up sample dependency") + .using(project(":compose")) + .because("we want to auto-wire up sample dependency") } } diff --git a/demos/supabase-todolist/androidApp/src/androidMain/kotlin/com/powersync/demos/MainActivity.kt b/demos/supabase-todolist/androidApp/src/androidMain/kotlin/com/powersync/demos/MainActivity.kt index de165a77..acef420d 100644 --- a/demos/supabase-todolist/androidApp/src/androidMain/kotlin/com/powersync/demos/MainActivity.kt +++ b/demos/supabase-todolist/androidApp/src/androidMain/kotlin/com/powersync/demos/MainActivity.kt @@ -8,7 +8,6 @@ import androidx.compose.material.Surface import com.powersync.DatabaseDriverFactory class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -21,4 +20,3 @@ class MainActivity : AppCompatActivity() { } } } - diff --git a/demos/supabase-todolist/gradle/libs.versions.toml b/demos/supabase-todolist/gradle/libs.versions.toml index 6a5d623d..81da7d11 100644 --- a/demos/supabase-todolist/gradle/libs.versions.toml +++ b/demos/supabase-todolist/gradle/libs.versions.toml @@ -12,6 +12,7 @@ coroutines = "1.8.1" kotlinx-datetime = "0.6.2" kotlinx-io = "0.5.4" ktor = "3.0.1" +sqliteJdbc = "3.45.2.0" uuid = "0.8.2" buildKonfig = "0.15.1" koin-bom = "4.0.2" @@ -35,6 +36,7 @@ androidx-material = "1.12.0" androidx-test-junit = "1.2.1" [libraries] +sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" } test-junit = { group = "junit", name = "junit", version.ref = "junit" } test-junitKtx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-junit" } test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } diff --git a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj index e8559884..57e2c8e9 100644 --- a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj @@ -248,6 +248,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + F72245E8E98E97BEF8C32493 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -397,10 +414,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - OTHER_LDFLAGS = ( - "$(inherited)", - "-lsqlite3", - ); + OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -425,10 +439,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - OTHER_LDFLAGS = ( - "$(inherited)", - "-lsqlite3", - ); + OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/demos/supabase-todolist/settings.gradle.kts b/demos/supabase-todolist/settings.gradle.kts index bcd0af04..73016629 100644 --- a/demos/supabase-todolist/settings.gradle.kts +++ b/demos/supabase-todolist/settings.gradle.kts @@ -11,22 +11,20 @@ pluginManagement { } } -val localProperties = Properties().apply { - try { - load(file("local.properties").reader()) - } catch (ignored: java.io.IOException) { - throw Error("local.properties file not found") +val localProperties = + Properties().apply { + try { + load(file("local.properties").reader()) + } catch (ignored: java.io.IOException) { + throw Error("local.properties file not found") + } } -} dependencyResolutionManagement { repositories { google() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") - maven("https://jitpack.io") { - content { includeGroup("com.github.requery") } - } } } @@ -47,9 +45,11 @@ if (!useReleasedVersions) { includeBuild("../..") { dependencySubstitution { substitute(module("com.powersync:core")) - .using(project(":core")).because("we want to auto-wire up sample dependency") + .using(project(":core")) + .because("we want to auto-wire up sample dependency") substitute(module("com.powersync:persistence")) - .using(project(":persistence")).because("we want to auto-wire up sample dependency") + .using(project(":persistence")) + .because("we want to auto-wire up sample dependency") substitute(module("com.powersync:connector-supabase")) .using(project(":connectors:supabase")) .because("we want to auto-wire up sample dependency") diff --git a/demos/supabase-todolist/shared/build.gradle.kts b/demos/supabase-todolist/shared/build.gradle.kts index abbdbe0e..f2a59ab1 100644 --- a/demos/supabase-todolist/shared/build.gradle.kts +++ b/demos/supabase-todolist/shared/build.gradle.kts @@ -27,7 +27,7 @@ kotlin { ios.deploymentTarget = "14.1" podfile = project.file("../iosApp/Podfile") pod("powersync-sqlite-core") { - version = "0.3.8" + version = "0.3.12" linkOnly = true } diff --git a/gradle.properties b/gradle.properties index e93b97f9..efa9bc09 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,7 @@ development=true RELEASE_SIGNING_ENABLED=true # Library config GROUP=com.powersync -LIBRARY_VERSION=1.0.0-BETA27 +LIBRARY_VERSION=1.0.0-BETA28 GITHUB_REPO=https://github.com/powersync-ja/powersync-kotlin.git # POM POM_URL=https://github.com/powersync-ja/powersync-kotlin/ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8cb5d30c..224c0422 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,8 +16,8 @@ kotlinx-io = "0.5.4" ktor = "3.0.1" uuid = "0.8.2" powersync-core = "0.3.12" -sqlite-android = "3.45.0" -sqlite-jdbc = "3.45.2.0" +sqlite-jdbc = "3.49.1.0" +sqliter = "1.3.1" turbine = "1.2.0" sqlDelight = "2.0.2" @@ -81,10 +81,10 @@ ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } -sqldelight-driver-ios = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } +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" } -requery-sqlite-android = { module = "com.github.requery:sqlite-android", version.ref = "sqlite-android" } 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" } diff --git a/persistence/build.gradle.kts b/persistence/build.gradle.kts index a4fc6f10..3caabf3a 100644 --- a/persistence/build.gradle.kts +++ b/persistence/build.gradle.kts @@ -36,15 +36,12 @@ kotlin { explicitApi() sourceSets { - commonMain.dependencies { api(libs.bundles.sqldelight) } androidMain.dependencies { - api(libs.sqldelight.driver.android) api(libs.powersync.sqlite.core.android) - api(libs.requery.sqlite.android) implementation(libs.androidx.sqliteFramework) } @@ -53,7 +50,8 @@ kotlin { } iosMain.dependencies { - api(libs.sqldelight.driver.ios) + api(libs.sqldelight.driver.native) + api(projects.staticSqliteDriver) } } } @@ -90,6 +88,8 @@ android { } sqldelight { + linkSqlite = false + databases { create("PsDatabase") { packageName.set("com.powersync.persistence") diff --git a/persistence/src/androidMain/kotlin/com/powersync/persistence/driver/AndroidSqliteDriver.kt b/persistence/src/androidMain/kotlin/com/powersync/persistence/driver/AndroidSqliteDriver.kt deleted file mode 100644 index f2627145..00000000 --- a/persistence/src/androidMain/kotlin/com/powersync/persistence/driver/AndroidSqliteDriver.kt +++ /dev/null @@ -1,409 +0,0 @@ -package com.powersync.persistence.driver - -import android.content.Context -import android.database.AbstractWindowedCursor -import android.database.Cursor -import android.database.CursorWindow -import android.os.Build -import android.util.LruCache -import androidx.annotation.DoNotInline -import androidx.annotation.RequiresApi -import androidx.sqlite.db.SupportSQLiteDatabase -import androidx.sqlite.db.SupportSQLiteOpenHelper -import androidx.sqlite.db.SupportSQLiteProgram -import androidx.sqlite.db.SupportSQLiteQuery -import androidx.sqlite.db.SupportSQLiteStatement -import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory -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 com.powersync.persistence.driver.Api28Impl.setWindowSize - -private const val DEFAULT_CACHE_SIZE = 20 - -public class AndroidSqliteDriver private constructor( - private val openHelper: SupportSQLiteOpenHelper? = null, - database: SupportSQLiteDatabase? = null, - private val cacheSize: Int, - private val windowSizeBytes: Long? = null, -) : SqlDriver { - init { - require((openHelper != null) xor (database != null)) - } - - private val transactions = ThreadLocal() - private val database by lazy { - openHelper?.writableDatabase ?: database!! - } - - public constructor( - openHelper: SupportSQLiteOpenHelper, - ) : this(openHelper = openHelper, database = null, cacheSize = DEFAULT_CACHE_SIZE, windowSizeBytes = null) - - /** - * @param [cacheSize] The number of compiled sqlite statements to keep in memory per connection. - * Defaults to 20. - * @param [useNoBackupDirectory] Sets whether to use a no backup directory or not. - * @param [windowSizeBytes] Size of cursor window in bytes, per [CursorWindow] (Android 28+ only), or null to use the default. - */ - @JvmOverloads - public constructor( - schema: SqlSchema>, - context: Context, - name: String? = null, - factory: SupportSQLiteOpenHelper.Factory = FrameworkSQLiteOpenHelperFactory(), - callback: SupportSQLiteOpenHelper.Callback = AndroidSqliteDriver.Callback(schema), - cacheSize: Int = DEFAULT_CACHE_SIZE, - useNoBackupDirectory: Boolean = false, - windowSizeBytes: Long? = null, - ) : this( - database = null, - openHelper = - factory.create( - SupportSQLiteOpenHelper.Configuration - .builder(context) - .callback(callback) - .name(name) - .noBackupDirectory(useNoBackupDirectory) - .build(), - ), - cacheSize = cacheSize, - windowSizeBytes = windowSizeBytes, - ) - - @JvmOverloads - public constructor( - database: SupportSQLiteDatabase, - cacheSize: Int = DEFAULT_CACHE_SIZE, - windowSizeBytes: Long? = null, - ) : this(openHelper = null, database = database, cacheSize = cacheSize, windowSizeBytes = windowSizeBytes) - - private val statements = - object : LruCache(cacheSize) { - override fun entryRemoved( - evicted: Boolean, - key: Int, - oldValue: AndroidStatement, - newValue: AndroidStatement?, - ) { - if (evicted) oldValue.close() - } - } - - private val listeners = linkedMapOf>() - - override fun addListener( - vararg queryKeys: String, - listener: Query.Listener, - ) { - synchronized(listeners) { - queryKeys.forEach { - listeners.getOrPut(it, { linkedSetOf() }).add(listener) - } - } - } - - override fun removeListener( - vararg queryKeys: String, - listener: Query.Listener, - ) { - synchronized(listeners) { - queryKeys.forEach { - listeners[it]?.remove(listener) - } - } - } - - override fun notifyListeners(vararg queryKeys: String) { - val listenersToNotify = linkedSetOf() - synchronized(listeners) { - queryKeys.forEach { listeners[it]?.let(listenersToNotify::addAll) } - } - listenersToNotify.forEach(Query.Listener::queryResultsChanged) - } - - override fun newTransaction(): QueryResult { - val enclosing = transactions.get() - val transaction = Transaction(enclosing) - transactions.set(transaction) - - if (enclosing == null) { - database.beginTransactionNonExclusive() - } - - return QueryResult.Value(transaction) - } - - override fun currentTransaction(): Transacter.Transaction? = transactions.get() - - internal inner class Transaction( - override val enclosingTransaction: Transacter.Transaction?, - ) : Transacter.Transaction() { - override fun endTransaction(successful: Boolean): QueryResult { - if (enclosingTransaction == null) { - if (successful) { - database.setTransactionSuccessful() - database.endTransaction() - } else { - database.endTransaction() - } - } - transactions.set(enclosingTransaction) - return QueryResult.Unit - } - } - - private fun execute( - identifier: Int?, - createStatement: () -> AndroidStatement, - binders: (SqlPreparedStatement.() -> Unit)?, - result: AndroidStatement.() -> T, - ): QueryResult.Value { - var statement: AndroidStatement? = null - if (identifier != null) { - statement = statements.remove(identifier) - } - if (statement == null) { - statement = createStatement() - } - try { - if (binders != null) { - statement.binders() - } - return QueryResult.Value(statement.result()) - } finally { - if (identifier != null) { - statements.put(identifier, statement)?.close() - } else { - statement.close() - } - } - } - - override fun execute( - identifier: Int?, - sql: String, - parameters: Int, - binders: (SqlPreparedStatement.() -> Unit)?, - ): QueryResult = execute(identifier, { AndroidPreparedStatement(database.compileStatement(sql)) }, binders, { execute() }) - - override fun executeQuery( - identifier: Int?, - sql: String, - mapper: (SqlCursor) -> QueryResult, - parameters: Int, - binders: (SqlPreparedStatement.() -> Unit)?, - ): QueryResult.Value = - execute( - identifier, - { AndroidQuery(sql, database, parameters, windowSizeBytes) }, - binders, - ) { executeQuery(mapper) } - - override fun close() { - statements.evictAll() - return openHelper?.close() ?: database.close() - } - - public open class Callback( - private val schema: SqlSchema>, - private vararg val callbacks: AfterVersion, - ) : SupportSQLiteOpenHelper.Callback( - if (schema.version > - Int.MAX_VALUE - ) { - error("Schema version is larger than Int.MAX_VALUE: ${schema.version}.") - } else { - schema.version.toInt() - }, - ) { - override fun onCreate(db: SupportSQLiteDatabase) { - schema.create(AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1)) - } - - override fun onUpgrade( - db: SupportSQLiteDatabase, - oldVersion: Int, - newVersion: Int, - ) { - schema.migrate( - AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1), - oldVersion.toLong(), - newVersion.toLong(), - *callbacks, - ) - } - } -} - -internal interface AndroidStatement : SqlPreparedStatement { - fun execute(): Long - - fun executeQuery(mapper: (SqlCursor) -> QueryResult): R - - fun close() -} - -private class AndroidPreparedStatement( - private val statement: SupportSQLiteStatement, -) : AndroidStatement { - override fun bindBytes( - index: Int, - bytes: ByteArray?, - ) { - if (bytes == null) statement.bindNull(index + 1) else statement.bindBlob(index + 1, bytes) - } - - override fun bindLong( - index: Int, - long: Long?, - ) { - if (long == null) statement.bindNull(index + 1) else statement.bindLong(index + 1, long) - } - - override fun bindDouble( - index: Int, - double: Double?, - ) { - if (double == null) statement.bindNull(index + 1) else statement.bindDouble(index + 1, double) - } - - override fun bindString( - index: Int, - string: String?, - ) { - if (string == null) statement.bindNull(index + 1) else statement.bindString(index + 1, string) - } - - override fun bindBoolean( - index: Int, - boolean: Boolean?, - ) { - if (boolean == null) { - statement.bindNull(index + 1) - } else { - statement.bindLong(index + 1, if (boolean) 1L else 0L) - } - } - - override fun executeQuery(mapper: (SqlCursor) -> QueryResult): R = throw UnsupportedOperationException() - - override fun execute(): Long = statement.executeUpdateDelete().toLong() - - override fun close() { - statement.close() - } -} - -private class AndroidQuery( - override val sql: String, - private val database: SupportSQLiteDatabase, - override val argCount: Int, - private val windowSizeBytes: Long?, -) : SupportSQLiteQuery, - AndroidStatement { - private val binds = MutableList<((SupportSQLiteProgram) -> Unit)?>(argCount) { null } - - override fun bindBytes( - index: Int, - bytes: ByteArray?, - ) { - binds[index] = { if (bytes == null) it.bindNull(index + 1) else it.bindBlob(index + 1, bytes) } - } - - override fun bindLong( - index: Int, - long: Long?, - ) { - binds[index] = { if (long == null) it.bindNull(index + 1) else it.bindLong(index + 1, long) } - } - - override fun bindDouble( - index: Int, - double: Double?, - ) { - binds[index] = { if (double == null) it.bindNull(index + 1) else it.bindDouble(index + 1, double) } - } - - override fun bindString( - index: Int, - string: String?, - ) { - binds[index] = { if (string == null) it.bindNull(index + 1) else it.bindString(index + 1, string) } - } - - override fun bindBoolean( - index: Int, - boolean: Boolean?, - ) { - binds[index] = { - if (boolean == null) { - it.bindNull(index + 1) - } else { - it.bindLong(index + 1, if (boolean) 1L else 0L) - } - } - } - - override fun execute() = throw UnsupportedOperationException() - - override fun executeQuery(mapper: (SqlCursor) -> QueryResult): R = - database - .query(this) - .use { cursor -> mapper(AndroidCursor(cursor, windowSizeBytes)).value } - - override fun bindTo(statement: SupportSQLiteProgram) { - for (action in binds) { - action!!(statement) - } - } - - override fun toString() = sql - - override fun close() {} -} - -private class AndroidCursor( - private val cursor: Cursor, - windowSizeBytes: Long?, -) : ColNamesSqlCursor { - init { - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && - windowSizeBytes != null && - cursor is AbstractWindowedCursor - ) { - cursor.setWindowSize(windowSizeBytes) - } - } - - override fun next(): QueryResult.Value = QueryResult.Value(cursor.moveToNext()) - - override fun getString(index: Int) = if (cursor.isNull(index)) null else cursor.getString(index) - - override fun getLong(index: Int) = if (cursor.isNull(index)) null else cursor.getLong(index) - - override fun getBytes(index: Int) = if (cursor.isNull(index)) null else cursor.getBlob(index) - - override fun getDouble(index: Int) = if (cursor.isNull(index)) null else cursor.getDouble(index) - - override fun getBoolean(index: Int) = if (cursor.isNull(index)) null else cursor.getLong(index) == 1L - - override fun columnName(index: Int): String? = cursor.getColumnName(index) - - override val columnCount: Int = cursor.columnCount -} - -@RequiresApi(Build.VERSION_CODES.P) -private object Api28Impl { - @JvmStatic - @DoNotInline - fun AbstractWindowedCursor.setWindowSize(windowSizeBytes: Long) { - window = CursorWindow(null, windowSizeBytes) - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 23fcbe5a..b7472215 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,9 +12,6 @@ dependencyResolutionManagement { google() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") - maven("https://jitpack.io") { - content { includeGroup("com.github.requery") } - } } } @@ -23,6 +20,7 @@ rootProject.name = "powersync-root" include(":core") include(":core-tests-android") include(":connectors:supabase") +include("static-sqlite-driver") include(":dialect") include(":persistence") 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..ce759aec --- /dev/null +++ b/static-sqlite-driver/build.gradle.kts @@ -0,0 +1,189 @@ +import java.io.File +import java.io.FileInputStream +import com.powersync.plugins.sonatype.setupGithubRepository +import de.undercouch.gradle.tasks.download.Download +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.utils.NativeCompilerDownloader +import org.jetbrains.kotlin.konan.properties.Properties +import org.jetbrains.kotlin.konan.target.HostManager +import org.jetbrains.kotlin.konan.target.KonanTarget +import org.jetbrains.kotlin.konan.target.PlatformManager + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.downloadPlugin) + id("com.powersync.plugins.sonatype") +} + +val sqliteVersion = "3490100" +val sqliteReleaseYear = "2025" + +setupGithubRepository() + +val downloads = layout.buildDirectory.dir("downloads") +val sqliteSrcFolder = downloads.map { it.dir("sqlite3") } + +val downloadSQLiteSources by tasks.registering(Download::class) { + val zipFileName = "sqlite-amalgamation-$sqliteVersion.zip" + src("https://www.sqlite.org/$sqliteReleaseYear/$zipFileName") + dest(downloads.map { it.file(zipFileName) }) + onlyIfNewer(true) + overwrite(false) +} + +val unzipSQLiteSources by tasks.registering(Copy::class) { + dependsOn(downloadSQLiteSources) + + from( + zipTree(downloadSQLiteSources.get().dest).matching { + include("*/sqlite3.*") + exclude { + it.isDirectory + } + eachFile { + this.path = this.name + } + }, + ) + into(sqliteSrcFolder) +} + +// 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 compileSqlite = tasks.register("${name}CompileSqlite") { + dependsOn(unzipSQLiteSources) + val targetDirectory = outputDir.get() + val sqliteSource = sqliteSrcFolder.map { it.file("sqlite3.c") }.get() + val output = targetDirectory.file("sqlite3.o") + + inputs.file(sqliteSource) + outputs.file(output) + + doFirst { + targetDirectory.asFile.mkdirs() + output.asFile.delete() + } + + doLast { + val (llvmTarget, sysRoot) = when (target.konanTarget) { + KonanTarget.IOS_X64 -> "x86_64-apple-ios12.0-simulator" to "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk" + KonanTarget.IOS_ARM64 -> "arm64-apple-ios12.0" to "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk" + KonanTarget.IOS_SIMULATOR_ARM64 -> "arm64-apple-ios14.0-simulator" to "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk" + else -> error("Unexpected target $target") + } + + providers.exec { + executable = "clang" + args( + "-B/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin", + "-fno-stack-protector", + "-target", + llvmTarget, + "-isysroot", + sysRoot, + "-fPIC", + "--compile", + "-I${sqliteSrcFolder.get().asFile.absolutePath}", + sqliteSource.asFile.absolutePath, + "-DHAVE_GETHOSTUUID=0", + "-DSQLITE_ENABLE_DBSTAT_VTAB", + "-DSQLITE_ENABLE_FTS5", + "-DSQLITE_ENABLE_RTREE", + "-O3", + "-o", + "sqlite3.o", + ) + + workingDir = targetDirectory.asFile + }.result.get() + } + } + + val createStaticLibrary = tasks.register("${name}ArchiveSqlite") { + dependsOn(compileSqlite) + val targetDirectory = outputDir.get() + inputs.file(targetDirectory.file("sqlite3.o")) + outputs.file(targetDirectory.file("libsqlite3.a")) + + doLast { + providers.exec { + executable = "ar" + args("rc", "libsqlite3.a", "sqlite3.o") + + workingDir = targetDirectory.asFile + }.result.get() + } + } + + val buildCInteropDef = tasks.register("${name}CinteropSqlite") { + dependsOn(createStaticLibrary) + + val archive = createStaticLibrary.get().outputs.files.singleFile + inputs.file(archive) + + val parent = archive.parentFile + val defFile = File(parent, "sqlite3.def") + outputs.file(defFile) + + doFirst { + defFile.writeText( + """ + package = com.powersync.sqlite3 + + linkerOpts.linux_x64 = -lpthread -ldl + linkerOpts.macos_x64 = -lpthread -ldl + staticLibraries=${archive.name} + libraryPaths=${parent.relativeTo(project.layout.projectDirectory.asFile.canonicalFile)} + """.trimIndent(), + ) + } + } + + return buildCInteropDef +} + +kotlin { + iosX64() + iosArm64() + iosSimulatorArm64() + + applyDefaultHierarchyTemplate() + + sourceSets { + all { + languageSettings.apply { + optIn("kotlin.experimental.ExperimentalNativeApi") + optIn("kotlinx.cinterop.ExperimentalForeignApi") + optIn("kotlinx.cinterop.BetaInteropApi") + } + } + + nativeTest { + dependencies { + implementation(libs.sqliter) + } + } + } + + targets.withType { + if (hostManager.isEnabled(konanTarget)) { + val compileSqlite3 = compileSqlite(this) + + compilations.named("main") { + cinterops.create("sqlite3") { + val cInteropTask = tasks[interopProcessingTaskName] + cInteropTask.dependsOn(compileSqlite3) + definitionFile = compileSqlite3.get().outputs.files.singleFile + includeDirs(sqliteSrcFolder.get()) + } + } + } + } +} diff --git a/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt b/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt new file mode 100644 index 00000000..7902aff6 --- /dev/null +++ b/static-sqlite-driver/src/nativeTest/kotlin/SmokeTest.kt @@ -0,0 +1,22 @@ +import co.touchlab.sqliter.DatabaseConfiguration +import co.touchlab.sqliter.createDatabaseManager +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() + + assertEquals(true, cursor.next()) + db.close() + } +}