diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 1d0d7319..04522322 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -1,4 +1,4 @@
-name: Deploy to central
+name: Deploy to Sonatype
on: workflow_dispatch
@@ -13,40 +13,44 @@ jobs:
strategy:
matrix:
include:
- - target: publishIosArm64PublicationToSonatypeRepository
+ - target: publishAllPublicationsToSonatypeRepository
os: macos-latest
- - target: publishAndroidReleasePublicationToSonatypeRepository
- os: ubuntu-latest
- - target: publishKotlinMultiplatformPublicationToSonatypeRepository
- os: ubuntu-latest
+# FIXME: Our custom gradle plugin does not currently setup platform specific tasks
+# - target: publishIosArm64PublicationToSonatypeRepository
+# os: macos-latest
+# - target: publishAndroidDebugPublicationToSonatypeRepository
+# os: ubuntu-latest
+# - target: publishAndroidReleasePublicationToSonatypeRepository
+# os: ubuntu-latest
+# - target: publishKotlinMultiplatformPublicationToSonatypeRepository
+# os: ubuntu-latest
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v3
- - name: Validate Gradle Wrapper
- uses: gradle/wrapper-validation-action@v1
- - uses: actions/cache@v3
- with:
- path: |
- ~/.konan
- key: ${{ runner.os }}-${{ hashFiles('**/.lock') }}
- - name: Import GPG key
- uses: crazy-max/ghaction-import-gpg@v5
- with:
- gpg_private_key: ${{ secrets.OSSRH_GPG_SECRET_KEY }}
- passphrase: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }}
- - name: Set up JDK 17
- uses: actions/setup-java@v3
- with:
- java-version: '17'
- distribution: 'temurin'
- - name: Gradle publish
- uses: gradle/gradle-build-action@ce999babab2de1c4b649dc15f0ee67e6246c994f
- with:
- arguments: |
- ${{ matrix.target }}
- closeSonatypeStagingRepository
- -Psigning.gnupg.passphrase='${{secrets.OSSRH_GPG_SECRET_KEY_PASSWORD}}'
- -Psigning.gnupg.keyName='${{secrets.OSSRH_GPG_SECRET_KEY_ID}}'
- -PsonatypeUsername='${{secrets.OSSRH_USERNAME}}'
- -PsonatypePassword='${{secrets.OSSRH_PASSWORD}}'
+ - uses: actions/checkout@v4
+ - name: Validate Gradle Wrapper
+ uses: gradle/wrapper-validation-action@v1
+ - uses: actions/cache@v3
+ with:
+ path: |
+ ~/.konan
+ key: ${{ runner.os }}-${{ hashFiles('**/.lock') }}
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v3
+
+ - name: Gradle publish
+ run: |
+ ./gradlew \
+ ${{ matrix.target }} \
+ -PGITHUB_PUBLISH_TOKEN="${{ secrets.GITHUB_TOKEN }}" \
+ -PsigningInMemoryKey="${{ secrets.SIGNING_KEY }}" \
+ -PsigningInMemoryKeyId="${{ secrets.SIGNING_KEY_ID }}" \
+ -PsigningInMemoryKeyPassword="${{ secrets.SIGNING_PASSWORD }}" \
+ -PcentralPortal.username="${{secrets.SONATYPE_USERNAME}}" \
+ -PcentralPortal.password="${{secrets.SONATYPE_PASSWORD}}"
diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index 8ce43f6a..80df3846 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -1,11 +1,4 @@
-# This workflow uses actions that are not certified by GitHub.
-# They are provided by a third-party and are governed by
-# separate terms of service, privacy policy, and support
-# documentation.
-# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle
-
-name: Java CI with Gradle
+name: Gradle build & test
on:
push:
@@ -24,15 +17,17 @@ jobs:
include:
- target: iosSimulatorArm64Test
os: macos-latest
- - target: jvmTest
+ - target: testDebugUnitTest
+ os: ubuntu-latest
+ - target: testReleaseUnitTest
os: ubuntu-latest
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
with:
path: |
~/.konan
@@ -42,7 +37,11 @@ jobs:
with:
java-version: '17'
distribution: 'temurin'
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v3
+
- name: Build with Gradle
- uses: gradle/gradle-build-action@ce999babab2de1c4b649dc15f0ee67e6246c994f
- with:
- arguments: ${{ matrix.target }}
\ No newline at end of file
+ run: |
+ ./gradlew \
+ ${{ matrix.target }} \
+ -PGITHUB_PUBLISH_TOKEN=${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/publish-swift.yml b/.github/workflows/publish-swift.yml
new file mode 100644
index 00000000..04900d13
--- /dev/null
+++ b/.github/workflows/publish-swift.yml
@@ -0,0 +1,21 @@
+name: Publish iOS Swift
+on:
+ workflow_dispatch:
+# TODO: Uncomment this if we want to publish on merge to main
+# push:
+# branches:
+# - "main"
+
+jobs:
+ call-kmmbridge-publish:
+ permissions:
+ contents: write
+ packages: write
+ uses: touchlab/KMMBridgeGithubWorkflow/.github/workflows/faktorybuildautoversion.yml@v1.1
+ with:
+ jvmVersion: 17
+ versionBaseProperty: LIBRARY_VERSION
+ publishTask: kmmBridgePublish
+ secrets:
+ gradle_params: -PsigningInMemoryKey="${{ secrets.SIGNING_KEY }}" -PsigningInMemoryKeyId="${{ secrets.SIGNING_KEY_ID }}" -PsigningInMemoryKeyPassword="${{ secrets.SIGNING_PASSWORD }}"
+# PODSPEC_SSH_KEY: ${{ secrets.PODSPEC_SSH_KEY }}
diff --git a/.gitignore b/.gitignore
index 878c2cd5..25cb8fab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,20 @@
*.iml
.gradle
-/local.properties
+**/build/
+xcuserdata
+!src/**/build/
+local.properties
.idea
+.fleet
.DS_Store
-/build
-/captures
+captures
.externalNativeBuild
-.cxx
-local.properties
+**/.cxx
+*.xcodeproj/*
+**/.swiftpm
+!*.xcodeproj/project.pbxproj
+!*.xcodeproj/xcshareddata/
+!*.xcodeproj/project.xcworkspace/
+!*.xcworkspace/contents.xcworkspacedata
+**/xcshareddata/WorkspaceSettings.xcsettings
+Pods/
\ No newline at end of file
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 00000000..5f0267b9
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,24 @@
+// swift-tools-version:5.3
+import PackageDescription
+
+let packageName = "powersyncswift"
+
+let package = Package(
+ name: packageName,
+ platforms: [
+ .iOS(.v13)
+ ],
+ products: [
+ .library(
+ name: packageName,
+ targets: [packageName]
+ ),
+ ],
+ targets: [
+ .binaryTarget(
+ name: packageName,
+ path: "./powersyncswift/build/XCFrameworks/debug/\(packageName).xcframework"
+ )
+ ,
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..7c7a3f7d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,253 @@
+
+
+
+
+[PowerSync](https://powersync.com) is a service and set of SDKs that keeps Postgres databases in sync with on-device SQLite databases.
+
+# PowerSync Kotlin Multiplatform SDK
+
+## Alpha release
+
+This SDK is currently in an alpha release. If you find a bug or issue, please open a [GitHub issue](https://github.com/powersync-ja/powersync-kotlin/issues). Questions or feedback can be posted on
+our [community Discord](https://discord.gg/powersync) - we'd love to hear from you.
+
+## SDK Features
+
+* Provides real-time streaming of database changes, using Kotlin Coroutines and Flows.
+* Offers direct access to the SQLite database, enabling the use of SQL on both client and server
+ sides.
+* Operations are asynchronous, ensuring the user interface remains unblocked.
+* Supports concurrent database operations, allowing one write and multiple reads simultaneously.
+* Enables subscription to queries for receiving live updates.
+* Eliminates the need for client-side database migrations as these are managed automatically.
+
+Supported KMP targets: Android and iOS.
+
+## Structure: Packages
+
+- [core](./core/)
+
+ - This is the Kotlin Multiplatform SDK implementation.
+
+- [connectors](./connectors/)
+
+ - [SupabaseConnector.kt](./connectors/src/commonMain/kotlin/com/powersync/connectors/SupabaseConnector.kt) An example connector implementation tailed for Supabase. The backend connector provides
+ the connection between your application backend and the PowerSync managed database. It is used to:
+ 1. Retrieve a token to connect to the PowerSync service.
+ 2. Apply local changes on your backend application server (and from there, to Postgres).
+
+## Demo Apps / Example Projects
+
+The easiest way to test the PowerSync KMP SDK is to run one of our demo applications.
+
+Demo applications are located in the [`demos/`](./demos) directory. See their respective README's for testing instructions:
+
+- [demos/hello-powersync](./demos/hello-powersync/README.md): A minimal example demonstrating the use of the PowerSync Kotlin Multiplatform SDK and the Supabase connector.
+
+- [demos/supabase-todolist](./demos/supabase-todolist/README.md): ** Currently a work in progress **
+ A simple to-do list application demonstrating the use of the PowerSync Kotlin Multiplatform SDK and the Supabase connector.
+
+## Current Limitations / Future work
+
+The PowerSync Kotlin Multiplatform SDK is currently in an alpha release and is not yet suitable for production use.
+
+Current limitations:
+- Debugging via configurable logging is not yet implemented.
+- 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).
+
+## Getting Started
+
+### Installation
+
+Add the PowerSync Kotlin Multiplatform SDK to your project by adding the following to your `build.gradle.kts` file:
+
+```kotlin
+
+kotlin {
+ //...
+ sourceSets {
+ commonMain.dependencies {
+ api("com.powersync:core:$powersyncVersion")
+ }
+ //...
+ }
+}
+```
+
+If want to use the Supabase Connector, also add the following to `commonMain.dependencies`:
+
+```kotlin
+ implementation("com.powersync:connectors:$powersyncVersion")
+```
+
+#### Cocoapods
+
+We recommend using Cocoapods (as opposed to SMP) for iOS targets. Add the following to the `cocoapods` config in your `build.gradle.kts`
+
+```kotlin
+cocoapods {
+ //...
+ pod("powersync-sqlite-core") {
+ linkOnly = true
+ }
+
+ framework {
+ isStatic = true
+ export("com.powersync:core")
+ }
+ //...
+}
+```
+
+Note: The `linkOnly` attribute is set to `true` and framework is set to `isStatic = true` to ensure that the `powersync-sqlite-core` binaries are only statically linked.
+
+### Usage
+
+The first step is creating a PowerSync account and setting up a PowerSync instance. If you are using Supabase, we have a step-by-step tutorial
+available [here](https://docs.powersync.com/integration-guides/supabase-+-powersync).
+
+For other Postgres backend providers, follow these steps:
+
+* Sign up for a free PowerSync account
+ here [https://www.powersync.com/](https://www.powersync.com/).
+* Visit the [PowerSync dashboard](https://powersync.journeyapps.com/) to create a PowerSync instance. After signing up you will be prompted to start the onboarding wizard which guides your though the
+ steps required for this, and find database specific
+ instructions [here](https://docs.powersync.com/usage/installation/database-setup). Existing users: start the onboarding wizard by navigating to Help > Start guide in the top-right corner.
+* Developer documentation for PowerSync is available [here](https://docs.powersync.com/).
+
+#### 1. Define the schema for the on-device SQLite database.
+
+You need to set up your schema in your app project. This involves defining your schema in code using the PowerSync syntax.
+This schema represents a "view" of the downloaded data. No migrations are required — the schema is applied directly when the PowerSync database is constructed.
+
+```kotlin
+import com.powersync.db.schema.Column
+import com.powersync.db.schema.Schema
+import com.powersync.db.schema.Table
+
+val schema: Schema = Schema(
+ listOf(
+ Table(
+ "customers",
+ listOf(
+ Column.text("name"),
+ Column.text("email")
+ )
+ )
+ )
+)
+
+```
+
+Note: No need to declare a primary key `id` column, as PowerSync will automatically create this.
+
+#### 2. Implement a backend connector to define how PowerSync communicates with your backend.
+
+The PowerSync backend connector provides the connection between your application backend and the PowerSync managed database.
+It is used to:
+1. Retrieve a token to connect to the PowerSync instance.
+2. Apply local changes on your backend application server (and from there, to Postgres)
+
+If you are using Supabase, you can use [SupabaseConnector.kt](./connectors/src/commonMain/kotlin/com/powersync/connectors/SupabaseConnector.kt) as a starting point.
+
+```kotlin
+class MyConnector: PowerSyncBackendConnector() {
+ override suspend fun fetchCredentials(): PowerSyncCredentials {
+ // implement fetchCredentials to obtain the necessary credentials to connect to your backend
+ // See an example implementation in connectors/src/commonMain/kotlin/com/powersync/connectors/SupabaseConnector.kt
+ }
+
+ override suspend fun uploadData(database: PowerSyncDatabase) {
+ // Implement uploadData to send local changes to your backend service
+ // You can omit this method if you only want to sync data from the server to the client
+ // See an example implementation in connectors/src/commonMain/kotlin/com/powersync/connectors/SupabaseConnector.kt
+ // See https://docs.powersync.com/usage/installation/app-backend-setup/writing-client-changes for considerations.
+ }
+}
+```
+
+#### 3. Initialize the PowerSync database an connect it to the connector, using `PowerSyncBuilder`:
+
+You need to instantiate the PowerSync database — this is the core managed database.
+Its primary functions are to record all changes in the local database, whether online or offline. In addition, it automatically uploads changes to your app backend when connected.
+
+ a. Create platform specific `DatabaseDriverFactory` to be used by the `PowerSyncBuilder` to create the SQLite database driver.
+ ```kotlin
+ // Android
+val driverFactory = DatabaseDriverFactory(this)
+
+// iOS
+val driverFactory = DatabaseDriverFactory()
+ ```
+
+b. Build a `PowerSyncDatabase` instance using the `PowerSyncBuilder` and the `DatabaseDriverFactory`. The schema you created in a previous step is also used as a parameter:
+
+ ```kotlin
+ // commonMain
+val database = PowerSyncBuilder.from(driverFactory, schema).build()
+ ```
+
+c. Connect the `PowerSyncDatabase` to the backend connector:
+
+ ```kotlin
+ // commonMain
+database.connect(MyConnector())
+ ```
+
+#### 4. Subscribe to changes in data
+
+```kotlin
+// You can watch any SQL query. This excutes a read query every time the source tables are modified.
+fun watchCustomers(): Flow> {
+ // TODO: implement your UI based on the result set
+ return database.watch("SELECT * FROM customers", mapper = { cursor ->
+ User(
+ id = cursor.getString(0)!!,
+ name = cursor.getString(1)!!,
+ email = cursor.getString(2)!!
+ )
+ })
+}
+```
+
+#### 5. Insert, update, and delete data in the local database
+
+The `execute` method executes a write query (INSERT, UPDATE, DELETE) and returns the results (if any).
+
+```kotlin
+suspend fun insertCustomer(name: String, email: String) {
+ database.writeTransaction {
+ database.execute(
+ "INSERT INTO customers (id, name, email) VALUES (uuid(), ?, ?)",
+ listOf(name, email)
+ )
+ }
+}
+
+suspend fun updateCustomer(id: String, name: String, email: String) {
+ database.execute(
+ "UPDATE customers SET name = ? WHERE email = ?",
+ listOf(name, email)
+ )
+}
+
+suspend fun deleteCustomer(id: String? = null) {
+ // If no id is provided, delete the first customer in the database
+ val targetId =
+ id ?: database.getOptional("SELECT id FROM customers LIMIT 1", mapper = { cursor ->
+ cursor.getString(0)!!
+ })
+ ?: return
+
+ database.writeTransaction {
+ database.execute("DELETE FROM customers WHERE id = ?", listOf(targetId))
+ }
+}
+```
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
deleted file mode 100644
index 42afabfd..00000000
--- a/app/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
deleted file mode 100644
index a061ab35..00000000
--- a/app/build.gradle.kts
+++ /dev/null
@@ -1,105 +0,0 @@
-plugins {
- id("com.android.application")
- id("org.jetbrains.kotlin.android")
- kotlin("plugin.serialization") version "1.4.30"
-}
-
-
-android {
- namespace = "com.example.notessqlite"
- compileSdk = 34
-
-// sourceSets {
-// getByName("main") {
-// jniLibs.srcDir("src/main/cpp/jniLibs")
-// }
-// }
-
- defaultConfig {
- applicationId = "com.example.notessqlite"
- minSdk = 28
- targetSdk = 34
- versionCode = 1
- versionName = "1.0"
-
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables {
- useSupportLibrary = true
- }
-// externalNativeBuild {
-// cmake {
-// cppFlags("-O2", "-fexceptions", "-frtti", "-std=c++1y")
-// }
-// }
- }
-
- buildTypes {
- release {
- isMinifyEnabled = false
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- debug {
- ndk {
- abiFilters.add("arm64-v8a")
- abiFilters.add("x86")
- abiFilters.add("x86_64")
- }
- }
- }
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = "1.8"
- }
- buildFeatures {
- compose = true
- }
- composeOptions {
- kotlinCompilerExtensionVersion = "1.4.3"
- }
- packaging {
- resources {
- excludes += "/META-INF/{AL2.0,LGPL2.1}"
- }
- }
-
- repositories {
- google()
- mavenCentral()
- maven {
- setUrl("https://jitpack.io")
- }
- }
-}
-
-dependencies {
- implementation("androidx.core:core-ktx:1.9.0")
- implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
- implementation("androidx.activity:activity-compose:1.7.0")
- implementation(platform("androidx.compose:compose-bom:2023.03.00"))
- implementation("androidx.compose.ui:ui")
- implementation("androidx.compose.ui:ui-graphics")
- implementation("androidx.compose.ui:ui-tooling-preview")
- implementation("androidx.compose.material3:material3")
- implementation("androidx.core:core-ktx:1.12.0")
- testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.5")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
- androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00"))
- androidTestImplementation("androidx.compose.ui:ui-test-junit4")
- debugImplementation("androidx.compose.ui:ui-tooling")
- debugImplementation("androidx.compose.ui:ui-test-manifest")
- implementation("androidx.constraintlayout:constraintlayout:2.1.4")
- implementation("androidx.recyclerview:recyclerview:1.3.2")
- implementation("com.github.requery:sqlite-android:3.43.0")
- implementation("co.powersync:powersync-sqlite-core:0.1.4")
- implementation("io.github.jan-tennert.supabase:postgrest-kt:1.4.1")
- implementation("io.ktor:ktor-client-cio:2.3.3")
- implementation("io.ktor:ktor-client-content-negotiation:2.3.3")
- implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
-}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
deleted file mode 100644
index 481bb434..00000000
--- a/app/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/example/notessqlite/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/notessqlite/ExampleInstrumentedTest.kt
deleted file mode 100644
index f8c95969..00000000
--- a/app/src/androidTest/java/com/example/notessqlite/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.example.notessqlite
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.example.notessqlite", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/AbstractPowerSyncDatabase.kt b/app/src/main/java/co/powersync/kotlin/AbstractPowerSyncDatabase.kt
deleted file mode 100644
index 42637669..00000000
--- a/app/src/main/java/co/powersync/kotlin/AbstractPowerSyncDatabase.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package co.powersync.kotlin
-
-abstract class AbstractPowerSyncDatabase {
-
- /**
- * Get the next recorded transaction to upload.
- *
- * Returns null if there is no data to upload.
- *
- * Use this from the [PowerSyncBackendConnector.uploadData]` callback.
- *
- * Once the data have been successfully uploaded, call [CrudTransaction.complete] before
- * requesting the next transaction.
- *
- * Unlike [getCrudBatch], this only returns data from a single transaction at a time.
- * All data for the transaction is loaded into memory.
- */
- abstract suspend fun getNextCrudTransaction(): Any?
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/Connector.kt b/app/src/main/java/co/powersync/kotlin/Connector.kt
deleted file mode 100644
index a8c68639..00000000
--- a/app/src/main/java/co/powersync/kotlin/Connector.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package co.powersync.kotlin
-
-import io.github.jan.supabase.SupabaseClient
-import kotlinx.datetime.Instant
-
-class PowerSyncCredentials(val client: SupabaseClient, val endpoint: String, val token: String, val expiresAt: Instant, val userID: String)
-
-abstract class PowerSyncBackendConnector {
- /// Get credentials for PowerSync.
- ///
- /// This should always fetch a fresh set of credentials - don't use cached
- /// values.
- ///
- /// Return null if the user is not signed in. Throw an error if credentials
- /// cannot be fetched due to a network error or other temporary error.
- ///
- /// This token is kept for the duration of a sync connection.
- abstract suspend fun fetchCredentials(): PowerSyncCredentials;
-
- /// Upload local changes to the app backend.
- ///
- /// Use [PowerSyncDatabase.getCrudBatch] to get a batch of changes to upload. See [DevConnector] for an example implementation.
- ///
- /// Any thrown errors will result in a retry after the configured wait period (default: 5 seconds).
- abstract suspend fun uploadData(database: AbstractPowerSyncDatabase);
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/DatabaseHelper.kt b/app/src/main/java/co/powersync/kotlin/DatabaseHelper.kt
deleted file mode 100644
index 27958e5f..00000000
--- a/app/src/main/java/co/powersync/kotlin/DatabaseHelper.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-package co.powersync.kotlin
-
-import android.content.Context
-import io.requery.android.database.sqlite.SQLiteCustomExtension
-import io.requery.android.database.sqlite.SQLiteDatabase
-import io.requery.android.database.sqlite.SQLiteDatabaseConfiguration
-import io.requery.android.database.sqlite.SQLiteOpenHelper
-
-class DatabaseHelper(context: Context): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION){
- companion object {
- private const val DATABASE_NAME = "employee.db"
- private const val DATABASE_VERSION = 1
-
- private const val CREATE_TABLE_EMPLOYEE = """
- CREATE TABLE employee (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- employee_name TEXT,
- employee_code TEXT,
- employee_image BLOB
- )
- """
- }
-
- override fun onCreate(db: SQLiteDatabase) {
- // create the employee table
- db.execSQL(CREATE_TABLE_EMPLOYEE)
- }
-
- override fun onOpen(db: SQLiteDatabase?) {
- super.onOpen(db)
- }
-
- override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
- // drop the employee table if it exists
- db.execSQL("DROP TABLE IF EXISTS employee")
-
- // create the new employee table
- onCreate(db)
- }
-
- // This provides the config each time a connection is made.
- override fun createConfiguration(path: String?, openFlags: Int): SQLiteDatabaseConfiguration {
- val config = super.createConfiguration(path, openFlags);
-
- config.customExtensions += SQLiteCustomExtension("libpowersync", "sqlite3_powersync_init");
-
- return config;
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/PowerSyncDatabase.kt b/app/src/main/java/co/powersync/kotlin/PowerSyncDatabase.kt
deleted file mode 100644
index 964e4c5f..00000000
--- a/app/src/main/java/co/powersync/kotlin/PowerSyncDatabase.kt
+++ /dev/null
@@ -1,568 +0,0 @@
-package co.powersync.kotlin
-
-import android.database.Cursor
-import android.os.StrictMode
-import co.powersync.kotlin.bucket.BucketChecksum
-import co.powersync.kotlin.bucket.BucketStorageAdapter
-import co.powersync.kotlin.bucket.Checkpoint
-import co.powersync.kotlin.bucket.CrudEntry
-import co.powersync.kotlin.bucket.KotlinBucketStorageAdapter
-import co.powersync.kotlin.bucket.SyncDataBatch
-import co.powersync.kotlin.bucket.SyncDataBucket
-import co.powersync.kotlin.db.Schema
-import co.powersync.kotlin.streaming_sync.BucketRequest
-import co.powersync.kotlin.streaming_sync.StreamingSyncCheckpointDiff
-import co.powersync.kotlin.streaming_sync.StreamingSyncRequest
-import co.powersync.kotlin.streaming_sync.isStreamingKeepalive
-import co.powersync.kotlin.streaming_sync.isStreamingSyncCheckpoint
-import co.powersync.kotlin.streaming_sync.isStreamingSyncCheckpointComplete
-import co.powersync.kotlin.streaming_sync.isStreamingSyncCheckpointDiff
-import co.powersync.kotlin.streaming_sync.isStreamingSyncData
-import com.russhwolf.settings.BuildConfig
-import io.ktor.client.HttpClient
-import io.ktor.client.call.body
-import io.ktor.client.engine.cio.CIO
-import io.ktor.client.plugins.HttpTimeout
-import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
-import io.ktor.client.plugins.timeout
-import io.ktor.client.request.get
-import io.ktor.client.request.headers
-import io.ktor.client.request.preparePost
-import io.ktor.client.request.setBody
-import io.ktor.client.statement.HttpResponse
-import io.ktor.http.ContentType
-import io.ktor.http.HttpHeaders
-import io.ktor.http.contentType
-import io.ktor.utils.io.ByteReadChannel
-import io.ktor.utils.io.core.isEmpty
-import io.ktor.utils.io.core.readBytes
-import kotlinx.coroutines.*
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flow
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.JsonElement
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.JsonPrimitive
-import kotlinx.serialization.json.decodeFromJsonElement
-import java.util.Date
-
-
-class CrudTransaction()
-
-data class HandleJSONInstructionResult(
- var targetCheckpoint: Checkpoint?,
- var validatedCheckpoint: Checkpoint?,
- var appliedCheckpoint: Checkpoint?,
-
- var bucketSet: MutableSet?,
- var retry: Boolean,
-)
-
-class PowerSyncDatabase(
- val dbHelper: DatabaseHelper,
- val schema: Schema,
- val connector: PowerSyncBackendConnector,
-) : AbstractPowerSyncDatabase() {
- private var sdkVersion: String? = null
-
- private var activeHttpResponse: HttpResponse? = null
- private var httpClient: HttpClient? = null
- private val bucketStorageAdapter: BucketStorageAdapter
- private var _lastSyncedAt: Date? = null
-
- private var isUploadingCrud = false //TODO Thread safe??
-
- init {
- if (BuildConfig.DEBUG) {
- StrictMode.enableDefaults()
- }
- bucketStorageAdapter = KotlinBucketStorageAdapter(dbHelper)
- }
-
- /**
- * Get the next recorded transaction to upload.
- *
- * Returns null if there is no data to upload.
- *
- * Use this from the [PowerSyncBackendConnector.uploadData]` callback.
- *
- * Once the data have been successfully uploaded, call [CrudTransaction.complete] before
- * requesting the next transaction.
- *
- * Unlike [getCrudBatch], this only returns data from a single transaction at a time.
- * All data for the transaction is loaded into memory.
- */
- override suspend fun getNextCrudTransaction(): CrudTransaction? {
- val database = dbHelper.writableDatabase
- database.beginTransaction()
- try {
- val cursor = database.rawQuery(
- "SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT 1",
- null
- )
- if (!cursor.moveToFirst()) {
- return null
- }
-
- val txIdIndex = cursor.getColumnIndex("tx_id")
- val txId = cursor.getInt(txIdIndex)
-
- val all: MutableList = mutableListOf()
-
- println("$txId $all")
-
- TODO("Not yet implemented")
- database.setTransactionSuccessful()
- } catch (e: Exception) {
- throw e
- } finally {
- database.endTransaction()
- database.close()
- }
-
-
- TODO("Not yet implemented")
- }
-
- private var initCompleted: CompletableDeferred? = null
-
- private suspend fun init() {
- initCompleted = CompletableDeferred()
-
- readSdkVersion()
- applySchema()
-
- httpClient = HttpClient(CIO) {
- install(HttpTimeout)
- install(ContentNegotiation)
- }
-
- bucketStorageAdapter.init()
-
- initCompleted?.complete(null)
- }
-
- private fun applySchema() {
- val database = dbHelper.readableDatabase
- val schemaJson = Json.encodeToString(schema)
- val query = "SELECT powersync_replace_schema(?)"
- println("Serialized app schema: $schemaJson")
- try {
- val cursor: Cursor = database.rawQuery(query, arrayOf(schemaJson))
- cursor.moveToNext()
- println("Result: ${cursor.columnNames.joinToString(" ")}")
- } catch (e: Exception) {
- println("Exception $e")
- }
-
- }
-
- private fun readSdkVersion() {
- val database = dbHelper.readableDatabase
-
- val query = "SELECT powersync_rs_version()"
- val cursor: Cursor = database.rawQuery(query, null)
- cursor.moveToNext()
- val idx = cursor.getColumnIndex("powersync_rs_version()")
-
- if (idx < 0) {
- // TODO, better error required?
- throw ArrayIndexOutOfBoundsException(
- "Cannot read powersync sdk version, no powersync_rs_version() column in table, Columns: ${
- cursor.columnNames.joinToString(
- " "
- )
- }"
- )
- }
-
- sdkVersion = cursor.getString(idx)
- cursor.close()
- }
-
- init {
- println("PowerSyncDatabase Init")
- runBlocking {
- init()
- }
- }
-
- suspend fun connect() {
- println("Powersync connecting")
- disconnect()
-
- // If isCompleted returns true, that means that init had completed (Failure also counts as completed)
- if (initCompleted?.isCompleted != true) {
- initCompleted?.await()
- }
-
- GlobalScope.launch (Dispatchers.IO){
- streamingSyncIteration()
- }
- }
-
- private fun disconnect() {
- if (activeHttpResponse == null) {
- return
- }
-
- println("Disconnecting existing http connection")
- activeHttpResponse?.cancel()
- }
-
- /**
- * Disconnect and clear the database.
- * Use this when logging out.
- * The database can still be queried after this is called, but the tables
- * would be empty.
- */
- fun disconnectAndClear() {
- val database = dbHelper.writableDatabase
- disconnect()
-
- // TODO DB name, verify this is necessary with extension
- database.beginTransaction()
- try {
- database.delete("ps_oplog", "1", arrayOf())
- database.delete("ps_crud", "1", arrayOf())
- database.delete("ps_buckets", "1", arrayOf())
-
- val existingTableRowsCursor = database.rawQuery(
- "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'ps_data_*'",
- arrayOf()
- )
-
- val existingTableRows = mutableSetOf()
- val nameIdx = existingTableRowsCursor.getColumnIndex("name")
- while (existingTableRowsCursor.moveToNext()) {
- existingTableRows.add(existingTableRowsCursor.getString(nameIdx))
- }
-
- if (existingTableRows.isEmpty()) {
- return
- }
-
- for (row in existingTableRows) {
- database.delete(row, "1", arrayOf())
- }
-
- database.setTransactionSuccessful()
- } finally {
- database.endTransaction()
- }
- }
-
- private suspend fun streamingSyncIteration() {
- val bucketEntries = bucketStorageAdapter.getBucketStates()
- val initialBuckets = mutableMapOf()
-
- val state = HandleJSONInstructionResult(
- targetCheckpoint = null,
- validatedCheckpoint = null,
- appliedCheckpoint = null,
- bucketSet = initialBuckets.keys.toMutableSet(),
- retry = false
- )
-
- bucketEntries.forEach { entry ->
- run {
- initialBuckets[entry.bucket] = entry.op_id
- }
- }
-
- val req: Array =
- initialBuckets.map { (bucket, after) -> BucketRequest(bucket, after) }.toTypedArray()
-
- streamingSyncRequest(
- StreamingSyncRequest(
- buckets = req,
- include_checksum = true
- )
- ).collect { value ->
- run {
- handleJSONInstruction(value, state)
- if (state.retry == true) {
- // Disconnect the current connection and re-connect with new token
- connect()
- }
- }
- }
- }
-
- // TODO use the return values e.g. {retry=true}, in react native sdk, the return values were for the locks, maybe whe need proper locks here as well
- private suspend fun handleJSONInstruction(
- jsonString: String,
- state: HandleJSONInstructionResult
- ): HandleJSONInstructionResult {
- println("Received Instruction: $jsonString")
- val obj: JsonObject = Json.parseToJsonElement(jsonString) as JsonObject
-
- when (true) {
- isStreamingSyncCheckpoint(obj) -> return handleStreamingSyncCheckpoint(obj, state)
- isStreamingSyncCheckpointComplete(obj) -> return handleStreamingSyncCheckpointComplete(
- obj,
- state
- )
-
- isStreamingSyncCheckpointDiff(obj) -> return handleStreamingSyncCheckpointDiff(
- obj,
- state
- )
-
- isStreamingSyncData(obj) -> return handleStreamingSyncData(obj, state)
- isStreamingKeepalive(obj) -> return handleStreamingKeepalive(obj, state)
- else -> {
- // TODO throw error?
- println("Unhandled JSON instruction")
- return state
- }
- }
- }
-
- suspend fun handleStreamingSyncCheckpoint(
- jsonObj: JsonObject,
- state: HandleJSONInstructionResult
- ): HandleJSONInstructionResult {
- val checkpoint =
- Json.decodeFromJsonElement(jsonObj["checkpoint"] as JsonElement)
- state.targetCheckpoint = checkpoint
- val bucketsToDelete = state.bucketSet!!.toMutableSet()
- val newBuckets = mutableSetOf()
-
- checkpoint.buckets?.forEach { checksum ->
- run {
- newBuckets.add(checksum.bucket)
- bucketsToDelete.remove(checksum.bucket)
- }
- }
-
- if (bucketsToDelete.size > 0) {
- println("Removing buckets [${bucketsToDelete.joinToString(separator = ", ")}]")
- }
-
- state.bucketSet = newBuckets
- bucketStorageAdapter.removeBuckets(bucketsToDelete.toTypedArray())
- bucketStorageAdapter.setTargetCheckpoint(checkpoint)
-
- return state
- }
-
- suspend fun handleStreamingSyncCheckpointComplete(
- jsonObj: JsonObject,
- state: HandleJSONInstructionResult
- ): HandleJSONInstructionResult {
- println("Checkpoint complete ${Json.encodeToString(state.targetCheckpoint)}")
- val result = bucketStorageAdapter.syncLocalDatabase(state.targetCheckpoint!!)
- if (!result.checkpointValid) {
- // This means checksums failed. Start again with a new checkpoint.
- // TODO: better back-off
- delay(50)
- state.retry = true
- // TODO handle retries
- return state
- } else if (!result.ready) {
- // Checksums valid, but need more data for a consistent checkpoint.
- // Continue waiting.
- // landing here the whole time
- } else {
- state.appliedCheckpoint = state.targetCheckpoint!!.clone()
- println("validated checkpoint ${state.appliedCheckpoint}")
- updateSyncStatus(true, Date())
- }
-
- state.validatedCheckpoint = state.targetCheckpoint
-
- return state
- }
-
- suspend fun handleStreamingSyncCheckpointDiff(
- jsonObj: JsonObject,
- state: HandleJSONInstructionResult
- ): HandleJSONInstructionResult {
- // TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint
- if (state.targetCheckpoint == null) {
- throw Exception("Checkpoint diff without previous checkpoint")
- }
- val checkpointDiff =
- Json.decodeFromJsonElement(jsonObj.get("checkpoint_diff")!!)
-
- val newBuckets = mutableMapOf()
-
- state.targetCheckpoint!!.buckets?.forEach { checksum ->
- newBuckets[checksum.bucket] = checksum
- }
- checkpointDiff.updated_buckets.forEach { checksum ->
- newBuckets[checksum.bucket] = checksum
- }
-
- checkpointDiff.removed_buckets.forEach { bucket -> newBuckets.remove(bucket) }
-
- val newCheckpoint = Checkpoint(
- last_op_id = checkpointDiff.last_op_id,
- buckets = newBuckets.values.toTypedArray(),
- write_checkpoint = checkpointDiff.write_checkpoint
- )
-
- state.targetCheckpoint = newCheckpoint
-
- state.bucketSet = newBuckets.keys.toMutableSet()
-
- val bucketsToDelete = checkpointDiff.removed_buckets.clone()
- if (bucketsToDelete.isNotEmpty()) {
- println("Remove buckets $bucketsToDelete")
- }
- bucketStorageAdapter.removeBuckets(bucketsToDelete)
- bucketStorageAdapter.setTargetCheckpoint(state.targetCheckpoint!!)
-
- return state
- }
-
- suspend fun handleStreamingSyncData(
- jsonObj: JsonObject,
- state: HandleJSONInstructionResult
- ): HandleJSONInstructionResult {
- val buckets = arrayOf(SyncDataBucket.fromRow(jsonObj["data"] as JsonObject))
- bucketStorageAdapter.saveSyncData(SyncDataBatch(buckets))
-
- return state
- }
-
- suspend fun handleStreamingKeepalive(
- jsonObj: JsonObject,
- state: HandleJSONInstructionResult
- ): HandleJSONInstructionResult {
- val tokenExpiresIn = (jsonObj["token_expires_in"] as JsonPrimitive).content.toInt()
-
- if (tokenExpiresIn <= 0) {
- // Connection would be closed automatically right after this
- println("Token expiring reconnect")
- state.retry = true
- return state
- }
- triggerCrudUpload()
- return state
- }
-
-
- private suspend fun streamingSyncRequest(req: StreamingSyncRequest): Flow = flow {
- val newLineChar = '\n'.code.toByte()
- var instructions: Array = arrayOf()
-
- val creds = connector.fetchCredentials()
- val psPath = creds.endpoint + "/sync/stream"
-
- val bodyJson = Json.encodeToString(req)
-
- val statement = httpClient!!.preparePost(psPath) {
- contentType(ContentType.Application.Json)
- headers {
- append(HttpHeaders.Authorization, "Token ${creds.token}")
- append("User-Id", creds.userID)
- }
- timeout {
- requestTimeoutMillis = Long.MAX_VALUE
- }
- setBody(bodyJson)
- }
-
- statement.execute { httpResponse ->
- // TODO is this the correct way to stop/disconnect the current connection?
- activeHttpResponse = httpResponse
- val channel: ByteReadChannel = httpResponse.body()
- var buffer: ByteArray = byteArrayOf()
- while (!channel.isClosedForRead) {
- val readLimit = channel.availableForRead.coerceAtMost(DEFAULT_BUFFER_SIZE).toLong()
- val packet = channel.readRemaining(readLimit)
-
- while (!packet.isEmpty) {
- val bytes = packet.readBytes()
- bytes.forEach { byte ->
- run {
- if (byte == newLineChar) {
- instructions += String(buffer)
- buffer = byteArrayOf()
- } else {
- buffer += byte
- }
- }
- }
- }
-
- if (instructions.isNotEmpty()) {
- instructions.forEach { instruction -> emit(instruction) }
- instructions = arrayOf()
- } else {
- // No more data to read right now and no instructions to emit, wait a bit
- // Maybe this isn't even necessary
- delay(1000)
- }
- }
- }
- }
-
- fun updateSyncStatus(connected: Boolean, lastSyncedAt: Date? = null) {
- _lastSyncedAt = lastSyncedAt ?: _lastSyncedAt
- // TODO event firing and listeners
- //iterateListeners((cb) => cb.statusChanged?.(new SyncStatus(connected, this.lastSyncedAt)))
- }
-
- suspend fun triggerCrudUpload() {
- if (isUploadingCrud) {
- return
- }
- _uploadAllCrud()
- }
-
- // TODO make sure about thread safety
- suspend fun _uploadAllCrud() {
- isUploadingCrud = true
- while (true) {
- try {
- val done = uploadCrudBatch()
- if (done) {
- isUploadingCrud = false
- break
- }
- } catch (ex: Exception) {
- this.updateSyncStatus(false)
- this.isUploadingCrud = false
- break
- }
- }
- }
-
- private suspend fun uploadCrudBatch(): Boolean {
- val hasCrud = bucketStorageAdapter.hasCrud()
- if (hasCrud) {
- uploadCrud()
- return false
- } else {
- bucketStorageAdapter.updateLocalTarget(cb = suspend { getWriteCheckpoint() })
- return true
- }
- }
-
- suspend fun uploadCrud() {
- // If isCompleted returns true, that means that init had completed (Failure also counts as completed)
- if (initCompleted?.isCompleted != true) {
- initCompleted?.await()
- }
-
- connector.uploadData(this)
- }
-
- suspend fun getWriteCheckpoint(): String {
- val creds = connector.fetchCredentials()
- val psPath = creds.endpoint + "/write-checkpoint2.json"
-
- val response = httpClient!!.get(psPath) {
- contentType(ContentType.Application.Json)
- headers {
- append(HttpHeaders.Authorization, "Token ${creds.token}")
- append("User-Id", creds.userID)
- }
- }
- return response.body() as String
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/SupaBaseConnector.kt b/app/src/main/java/co/powersync/kotlin/SupaBaseConnector.kt
deleted file mode 100644
index e224a46d..00000000
--- a/app/src/main/java/co/powersync/kotlin/SupaBaseConnector.kt
+++ /dev/null
@@ -1,141 +0,0 @@
-package co.powersync.kotlin
-import io.github.jan.supabase.SupabaseClient
-import io.github.jan.supabase.createSupabaseClient
-import io.github.jan.supabase.gotrue.GoTrue
-import io.github.jan.supabase.gotrue.gotrue
-import io.github.jan.supabase.gotrue.providers.builtin.Email
-import io.github.jan.supabase.postgrest.Postgrest
-import kotlinx.coroutines.runBlocking
-
-class SupaBaseConnector: PowerSyncBackendConnector() {
-
- companion object {
- // TODO this needs to be provided by the user/dev
- private const val POWERSYNC_URL = "https://6528055654e498b08254c372.powersync.journeyapps.com"
- private const val SUPABASE_URL = "https://hlrnpckmfalpixgmpvxb.supabase.co"
- private const val SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhscm5wY2ttZmFscGl4Z21wdnhiIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTcxMjA0NzgsImV4cCI6MjAxMjY5NjQ3OH0.kQH5mN7ggAWE_9RGlrd9jnVSUbK3kQle1PIyvMGmRvg"
-
- private const val TEST_EMAIL = "hello@example.com";
- private const val TEST_PASSWORD = "123456";
- }
-
- private val supabaseClient: SupabaseClient;
-
- init {
- supabaseClient = createClient()
-
- runBlocking {
- val loginRes = login();
- val creds = fetchCredentials();
- println("Creds $creds")
- println("login $loginRes")
- }
- }
-
- private fun createClient(): SupabaseClient {
- val client = createSupabaseClient(
- supabaseUrl = SUPABASE_URL,
- supabaseKey = SUPABASE_KEY
- ) {
- install(GoTrue)
- install(Postgrest)
- }
-
- return client
- }
-
- private suspend fun login(): Unit {
- val res = supabaseClient.gotrue.loginWith(Email){
- email = TEST_EMAIL
- password = TEST_PASSWORD
- }
-
- return res;
- }
-
- override suspend fun fetchCredentials(): PowerSyncCredentials {
- val session = supabaseClient.gotrue.currentSessionOrNull();
-
- if (session == null) {
- throw Exception("Could not fetch Supabase credentials")
- }
-
- if (session.user == null) {
- throw Exception("No user data")
- }
-
- println("session expires at " + session.expiresAt);
-
- return PowerSyncCredentials(
- client = supabaseClient,
- endpoint = POWERSYNC_URL,
- token = session.accessToken,
- expiresAt = session.expiresAt,
- // !! required because error: Smart cast to 'UserInfo' is impossible, because 'session.user' is a public API property declared in different module
- // TODO figure out what error is about
- userID = session.user!!.id
- );
- }
-
- override suspend fun uploadData(database: AbstractPowerSyncDatabase) {
- val transaction = database.getNextCrudTransaction() ?: return
-
- }
-
- // TODO implement uploadData
-// suspend fun uploadData(database: AbstractPowerSyncDatabase): Promise {
-// const transaction = await database.getNextCrudTransaction();
-//
-// if (!transaction) {
-// return;
-// }
-//
-// let lastOp: CrudEntry | null = null;
-// try {
-// // Note: If transactional consistency is important, use database functions
-// // or edge functions to process the entire transaction in a single call.
-// for (let op of transaction.crud) {
-// lastOp = op;
-// const table = this.supabaseClient.from(op.table);
-// switch (op.op) {
-// case UpdateType.PUT:
-// const record = { ...op.opData, id: op.id };
-// const { error } = await table.upsert(record);
-// if (error) {
-// throw new Error(`Could not upsert data to Supabase ${JSON.stringify(error)}`);
-// }
-// break;
-// case UpdateType.PATCH:
-// await table.update(op.opData).eq('id', op.id);
-// break;
-// case UpdateType.DELETE:
-// await table.delete().eq('id', op.id);
-// break;
-// }
-// }
-//
-// await transaction.complete();
-// } catch (ex: any) {
-// console.debug(ex);
-// if (typeof ex.code == 'string' && FATAL_RESPONSE_CODES.some((regex) => regex.test(ex.code))) {
-// /**
-// * Instead of blocking the queue with these errors,
-// * discard the (rest of the) transaction.
-// *
-// * Note that these errors typically indicate a bug in the application.
-// * If protecting against data loss is important, save the failing records
-// * elsewhere instead of discarding, and/or notify the user.
-// */
-// console.error(`Data upload error - discarding ${lastOp}`, ex);
-// await transaction.complete();
-// } else {
-// // Error may be retryable - e.g. network error or temporary server error.
-// // Throwing an error here causes this call to be retried after a delay.
-// throw ex;
-// }
-// }
-// }
-
-
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/bucket/BucketStorageAdapter.kt b/app/src/main/java/co/powersync/kotlin/bucket/BucketStorageAdapter.kt
deleted file mode 100644
index 76c57af4..00000000
--- a/app/src/main/java/co/powersync/kotlin/bucket/BucketStorageAdapter.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package co.powersync.kotlin.bucket
-
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
-
-class SyncLocalDatabaseResult (
- val checkpointValid: Boolean,
- val ready: Boolean,
- val failures: Array ?= null
-)
-
-@Serializable
-data class BucketChecksum (
- val bucket: String,
- /**
- * 32-bit unsigned hash.
- */
- val checksum: Int,
-
- /**
- * Count of operations - informational only.
- */
- val count: Int ?= null
-)
-
-@Serializable
-data class Checkpoint (
- val last_op_id: String,
- val buckets: Array ?= null,
- val write_checkpoint: String ?= null,
-) {
- fun clone(): Checkpoint {
- return Checkpoint(last_op_id, buckets?.clone(), write_checkpoint)
- }
-
- override fun toString(): String {
- return Json.encodeToString(this)
- }
-}
-
-class BucketState (
- val bucket: String,
- val op_id: String
-)
-
-abstract class BucketStorageAdapter {
- abstract suspend fun init()
- abstract suspend fun saveSyncData(batch: SyncDataBatch)
- abstract suspend fun removeBuckets(buckets: Array)
-
- // operation = change to data, checkpoint = where in the oplog we are
- abstract suspend fun setTargetCheckpoint(checkpoint: Checkpoint)
-
- abstract fun startSession()
-
- abstract suspend fun getBucketStates(): Array
-
- abstract suspend fun syncLocalDatabase(checkpoint: Checkpoint): SyncLocalDatabaseResult
-
- abstract suspend fun hasCrud(): Boolean
- abstract suspend fun getCrudBatch(limit: Int?): CrudBatch?
-
- abstract suspend fun hasCompletedSync(): Boolean
- abstract suspend fun updateLocalTarget(cb: suspend () -> String): Boolean
-
- /**
- * Exposed for tests only.
- */
- abstract suspend fun autoCompact()
-
- /**
- * Exposed for tests only.
- */
- abstract suspend fun forceCompact()
-
- abstract fun getMaxOpId(): String
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/bucket/CrudBatch.kt b/app/src/main/java/co/powersync/kotlin/bucket/CrudBatch.kt
deleted file mode 100644
index c7d03202..00000000
--- a/app/src/main/java/co/powersync/kotlin/bucket/CrudBatch.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package co.powersync.kotlin.bucket
-
-class CrudBatch (
- val crud: Array,
- val haveMore: Boolean,
- val complete: (writeCheckpoint: Boolean?) -> Unit
-)
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/bucket/CrudEntry.kt b/app/src/main/java/co/powersync/kotlin/bucket/CrudEntry.kt
deleted file mode 100644
index 777711ed..00000000
--- a/app/src/main/java/co/powersync/kotlin/bucket/CrudEntry.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package co.powersync.kotlin.bucket
-
-enum class UpdateType {
- PUT ,
- PATCH,
- DELETE
-}
-class CrudEntry (
- val clientId: Int,
- val op: UpdateType,
- val table: String,
- val id: String,
- val transactionId: Int?,
- val opData: Map?
-)
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/bucket/KotlinBucketStorageAdapter.kt b/app/src/main/java/co/powersync/kotlin/bucket/KotlinBucketStorageAdapter.kt
deleted file mode 100644
index 6a64412c..00000000
--- a/app/src/main/java/co/powersync/kotlin/bucket/KotlinBucketStorageAdapter.kt
+++ /dev/null
@@ -1,475 +0,0 @@
-package co.powersync.kotlin.bucket
-
-import android.content.ContentValues
-import android.database.Cursor
-import co.powersync.kotlin.DatabaseHelper
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
-import java.util.UUID
-
-val COMPACT_OPERATION_INTERVAL = 1_000
-
-@Serializable
-data class ValidatedCheckpointResult(
- val valid: Boolean,
- val failed_buckets: Array
-)
-
-class KotlinBucketStorageAdapter(
- private val dbHelp: DatabaseHelper,
-) : BucketStorageAdapter() {
- companion object {
- private const val MAX_OP_ID = "9223372036854775807"
- }
-
- private val tableNames: MutableSet = mutableSetOf()
-
- // TODO thread safe?!
- private var _hasCompletedSync = false
-
- // TODO thread safe?!
- private var pendingBucketDeletes = false
-
- /**
- * Count up, and do a compact on startup.
- */
- private var compactCounter = COMPACT_OPERATION_INTERVAL
- override suspend fun init() {
- _hasCompletedSync = false
-
- readTableNames()
- }
-
- private fun readTableNames() {
- val database = dbHelp.readableDatabase
- tableNames.clear()
- // Query to get existing table names
- val query = "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'ps_data_*'"
- val cursor: Cursor = database.rawQuery(query, null)
- val nameIndex = cursor.getColumnIndex("name")
- while (cursor.moveToNext()) {
- val name = cursor.getString(nameIndex)
- tableNames.add(name)
- }
-
- cursor.close()
- }
-
- override suspend fun saveSyncData(batch: SyncDataBatch) {
- hasCompletedSync()
- val database = dbHelp.writableDatabase
- database.beginTransaction()
-
- try {
- var count = 0
- for (b in batch.buckets) {
-
- val bucketJsonStr = Json.encodeToString(SyncDataBatch(arrayOf(b)))
- val values = ContentValues().apply {
- put("op", "save")
- // TODO what prevents us from just using batch directly? instead of wrapping each bucket into each own batch
- put("data", bucketJsonStr)
- }
-
- val insertId = database.insert("powersync_operations", null, values)
-
- if (insertId == -1L) {
- // Error occurred according to docs
- println("Something went wrong!")
- }
-
- count += b.data.size
- compactCounter += count
- }
-
- database.setTransactionSuccessful()
- } finally {
- database.endTransaction()
- database.close()
- }
-
- hasCompletedSync()
- println("Finished inserting into powersync_operations")
- }
-
- override suspend fun removeBuckets(buckets: Array) {
- for (bucket in buckets) {
- deleteBucket(bucket)
- }
- }
-
- fun deleteBucket(bucket: String) {
- // Delete a bucket, but allow it to be re-created.
- // To achieve this, we rename the bucket to a new temp name, and change all ops to remove.
- // By itself, this new bucket would ensure that the previous objects are deleted if they contain no more references.
- // If the old bucket is re-added, this new bucket would have no effect.
- val newName = "\$delete_${bucket}_${UUID.randomUUID()}"
- println("Deleting bucket $bucket")
-
- val database = dbHelp.writableDatabase
- database.beginTransaction()
-
- try {
- val values = ContentValues().apply {
- put("op", "\"${OpTypeEnum.REMOVE}\"")
- put("data", "NULL")
- }
-
- val where = "op=\"${OpTypeEnum.PUT}\" AND superseded=0 AND bucket=?"
-
- val args = arrayOf(bucket)
-
- database.update("ps_oplog", values, where, args)
-
- // Rename the bucket to the new name
- database.update(
- "ps_oplog",
- ContentValues().apply {
- put("bucket", newName)
- },
- "bucket=?",
- arrayOf(bucket)
- )
-
- database.delete("ps_buckets", "name = ?", arrayOf(bucket))
- val res4Cursor = database.rawQuery(
- "INSERT INTO ps_buckets(name, pending_delete, last_op) SELECT ?, 1, IFNULL(MAX(op_id), 0) FROM ps_oplog WHERE bucket = ?",
- arrayOf(newName, newName)
- )
-
- res4Cursor.close()
- database.setTransactionSuccessful()
- }catch(e: Exception) {
- println("deleteBucket Failed ${e.message}")
- } finally {
- database.endTransaction()
- }
- println("done deleting bucket")
- pendingBucketDeletes = true
- }
-
- override suspend fun setTargetCheckpoint(checkpoint: Checkpoint) {
- // No-op for now
- }
-
- override fun startSession() {
- // Do nothing, yet
- }
-
- override suspend fun getBucketStates(): Array {
- val database = dbHelp.readableDatabase
-
- val buckets = mutableSetOf()
- val query =
- "SELECT name as bucket, cast(last_op as TEXT) as op_id FROM ps_buckets WHERE pending_delete = 0"
- val cursor: Cursor = database.rawQuery(query, null)
- val bucketIndex = cursor.getColumnIndex("bucket")
- val opIdIndex = cursor.getColumnIndex("op_id")
- while (cursor.moveToNext()) {
- val bucket = cursor.getString(bucketIndex)
- val opId = cursor.getString(opIdIndex)
- buckets.add(BucketState(bucket, opId))
- }
-
- cursor.close()
-
- // TODO maybe we can stick to set? or list TODO x2 read up on list vs map vs set in Kotlin world
- return buckets.toTypedArray()
- }
-
- fun validateChecksums(checkpoint: Checkpoint): SyncLocalDatabaseResult {
- val database = dbHelp.readableDatabase
-
- val query = "SELECT powersync_validate_checkpoint(?) as result"
- val dataStrArr = arrayOf(Json.encodeToString(checkpoint))
-
- val resJsonStr = database.stringForQuery(query, dataStrArr)
-
- if (resJsonStr.isEmpty()) {
- return SyncLocalDatabaseResult(
- checkpointValid = false,
- ready = false,
- failures = arrayOf()
- )
- }
-
- val result = Json.decodeFromString(resJsonStr)
-
- if (result.valid) {
- return SyncLocalDatabaseResult(
- checkpointValid = true,
- ready = true
- )
- }
-
- return SyncLocalDatabaseResult(
- checkpointValid = false,
- ready = false,
- failures = result.failed_buckets
- )
- }
-
- override suspend fun syncLocalDatabase(checkpoint: Checkpoint): SyncLocalDatabaseResult {
- val r = validateChecksums(checkpoint)
-
- if (!r.checkpointValid) {
- // TODO error message here
- println("Checksums failed for ${r.failures?.joinToString( separator =", " )}")
- if (r.failures?.isNotEmpty() == true) {
- for (b in r.failures) {
- deleteBucket(b)
- }
- }
-
- return SyncLocalDatabaseResult(
- ready = false,
- checkpointValid = false,
- failures = r.failures
- )
- }
-
- val bucketNames: List? = checkpoint.buckets?.map { b -> b.bucket }
-
- val database = dbHelp.writableDatabase
- database.beginTransaction()
-
- try {
- val query =
- "UPDATE ps_buckets SET last_op = ? WHERE name IN (SELECT json_each.value FROM json_each(?))"
- val bucketJson = Json.encodeToString(bucketNames)
- database.rawQuery(
- query,
- arrayOf(checkpoint.last_op_id, bucketJson)
- )
-
- if (checkpoint.write_checkpoint?.isNotEmpty() == true) {
- val query2 = "UPDATE ps_buckets SET last_op = ? WHERE name = '\$local'"
- database.rawQuery(query2, arrayOf( checkpoint.write_checkpoint))
- }
-
- database.setTransactionSuccessful()
- } finally {
- database.endTransaction()
- }
-
- val valid = updateObjectsFromBuckets()
- if (!valid) {
- println("Not at a consistent checkpoint - cannot update local db")
- return SyncLocalDatabaseResult(
- ready = false,
- checkpointValid = true
- )
- }
-
- forceCompact()
-
- return SyncLocalDatabaseResult(
- ready = true,
- checkpointValid = true
- )
- }
-
- fun updateObjectsFromBuckets(): Boolean{
- /**
- * It's best to execute this on the same thread
- * https://github.com/journeyapps/powersync-sqlite-core/blob/40554dc0e71864fe74a0cb00f1e8ca4e328ff411/crates/sqlite/sqlite/sqlite3.h#L2578
- */
-
- val database = dbHelp.writableDatabase
- database.beginTransaction()
- try {
- val res = database.insert("powersync_operations", null, ContentValues().apply {
- put("op", "sync_local")
- put("data", "")
- })
-
- database.setTransactionSuccessful()
-
- return res == 1L
- }
- catch (e:Exception){
- // TODO proper error message
- println("updateObjectsFromBuckets Error ${e.message}")
- }
- finally {
- database.endTransaction()
- database.close()
- }
-
- return false
- }
-
- override suspend fun hasCrud(): Boolean {
- val database = dbHelp.readableDatabase
-
- val query = "SELECT 1 FROM ps_crud LIMIT 1"
- val cursor: Cursor = database.rawQuery(query, null)
-
- val hasCrud = cursor.count > 0
- cursor.close()
- return hasCrud
- }
-
- override suspend fun getCrudBatch(limit: Int?): CrudBatch? {
- TODO("Not yet implemented")
- }
-
- override suspend fun hasCompletedSync(): Boolean {
- if (_hasCompletedSync) {
- return true;
- }
-
- val database = dbHelp.readableDatabase;
- val cursor = database.rawQuery("SELECT name, last_applied_op FROM ps_buckets WHERE last_applied_op > 0 LIMIT 1", null)
-
- val completed = cursor.count > 0;
-
- if (completed) {
- this._hasCompletedSync = true;
- }
-
- database.close()
- return completed;
- }
-
- override suspend fun updateLocalTarget(cb: suspend () -> String): Boolean {
- val database = dbHelp.writableDatabase
-
- val rs1Cursor = database.rawQuery(
- "SELECT target_op FROM ps_buckets WHERE name = '\$local' AND target_op = ?",
- arrayOf( MAX_OP_ID)
- )
-
- if (rs1Cursor.count == 0) {
- rs1Cursor.close()
- database.close()
- // Nothing to update
- return false
- }
- rs1Cursor.close()
-
- val rsCursor = database.rawQuery(
- "SELECT seq FROM sqlite_sequence WHERE name = 'ps_crud'", arrayOf()
- )
-
- if (rsCursor.count == 0) {
- rsCursor.close()
- database.close()
- // Nothing to update
- return false
- }
-
- rsCursor.moveToFirst()
- val seqIndex = rsCursor.getColumnIndex("seq")
- val seqBefore = rsCursor.getInt(seqIndex)
-
- val opId = cb()
-
- println("[updateLocalTarget] Updating target to checkpoint $opId");
-
- database.beginTransaction()
-
- try {
- val anyData = database.stringForQuery("SELECT 1 FROM ps_crud LIMIT 1", null)
- if( anyData.isNotEmpty()) {
- // if isNotEmpty
- println("updateLocalTarget ps crud is not empty")
- return false;
- }
-
- val cursor = database.rawQuery("SELECT seq FROM sqlite_sequence WHERE name = 'ps_crud'", null)
-
- if(cursor.moveToFirst()){
- // assert isNotEmpty
- throw Exception("SQlite Sequence should not be empty")
- }
-
- val seqIndex = cursor.getColumnIndex("item")
- val item = cursor.getInt(seqIndex)
- val seqAfter = cursor.getInt(seqIndex)
-
-// println("seqAfter ${Json.encodeToString()}", JSON.stringify(rs.rows?.item(0)));
- }
- catch (e: Exception){
- throw e
- }
- finally {
- database.endTransaction()
- database.close()
- }
-
-// this.logger.debug('seqAfter', JSON.stringify(rs.rows?.item(0)));
-// if (seqAfter != seqBefore) {
-// this.logger.debug('seqAfter != seqBefore', seqAfter, seqBefore);
-// // New crud data may have been uploaded since we got the checkpoint. Abort.
-// return false;
-// }
-//
-// const response = await tx.executeAsync("UPDATE ps_buckets SET target_op = ? WHERE name='$local'", [opId]);
-// this.logger.debug(['[updateLocalTarget] Response from updating target_op ', JSON.stringify(response)]);
-// return true;
-// });
-
- TODO("Not yet implemented")
- }
-
- suspend fun deletePendingBuckets() {
- if (pendingBucketDeletes != false) {
- val database = dbHelp.writableDatabase
-
- database.beginTransaction()
- try {
- database.execSQL("DELETE FROM ps_oplog WHERE bucket IN (SELECT name FROM ps_buckets WHERE pending_delete = 1 AND last_applied_op = last_op AND last_op >= target_op)")
- database.execSQL("DELETE FROM ps_buckets WHERE pending_delete = 1 AND last_applied_op = last_op AND last_op >= target_op")
-
- // Executed once after start-up, and again when there are pending deletes.
- pendingBucketDeletes = false
- database.setTransactionSuccessful()
- }
- finally {
- database.endTransaction()
- database.close()
- }
- }
- }
-
- private fun clearRemoveOps(){
- if (compactCounter < COMPACT_OPERATION_INTERVAL) {
- return;
- }
-
- val database = dbHelp.writableDatabase
-
- database.beginTransaction()
- try {
- database.insert("powersync_operations", null,ContentValues().apply {
- put("op", "clear_remove_ops")
- put("data", "")
- } )
-
- compactCounter = 0
- database.setTransactionSuccessful()
- }
- finally {
- database.endTransaction()
- database.close()
- }
- }
-
- override suspend fun autoCompact() {
- deletePendingBuckets()
- clearRemoveOps()
- }
-
- override suspend fun forceCompact() {
- compactCounter = COMPACT_OPERATION_INTERVAL
- pendingBucketDeletes = true
-
- autoCompact()
- }
-
- override fun getMaxOpId(): String {
- return MAX_OP_ID
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/bucket/OpType.kt b/app/src/main/java/co/powersync/kotlin/bucket/OpType.kt
deleted file mode 100644
index d3828b9e..00000000
--- a/app/src/main/java/co/powersync/kotlin/bucket/OpType.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package co.powersync.kotlin.bucket
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-enum class OpTypeEnum {
- CLEAR,
- MOVE,
- PUT,
- REMOVE
-}
diff --git a/app/src/main/java/co/powersync/kotlin/bucket/OplogEntry.kt b/app/src/main/java/co/powersync/kotlin/bucket/OplogEntry.kt
deleted file mode 100644
index 8007e150..00000000
--- a/app/src/main/java/co/powersync/kotlin/bucket/OplogEntry.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package co.powersync.kotlin.bucket
-
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonNull
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.JsonPrimitive
-
-@Serializable
-data class OplogEntryJSON (
- val op_id: String,
- val op: String,
- val object_type: String,
- val object_id: String,
- val checksum: Int,
- val data: JsonObject,
- val subkey: String
-)
-
-@Serializable
-data class OplogEntry (
- val op_id: String,
- val op: OpTypeEnum,
- val checksum: Long,
- /// Together with rowType and rowId, this uniquely identifies a source entry
- /// per bucket in the oplog. There may be multiple source entries for a single
- /// "rowType + rowId" combination.
- val subkey: String?= null,
- val object_type: String?,
- val object_id: String?,
- val data: MutableMap?
-) {
- companion object {
- fun fromRow (row: JsonObject): OplogEntry {
-
- var dataMap: MutableMap? = null
- if(row["data"] !is JsonNull){
- dataMap= mutableMapOf()
- val dataObj = row["data"] as JsonObject
- dataObj.forEach{i -> dataMap[i.key] = (i.value as JsonPrimitive).content }
- }
-
- val opType: OpTypeEnum = OpTypeEnum.valueOf((row["op"] as JsonPrimitive).content)
- val op = opType
-
- return OplogEntry(
- object_id = (row["object_id"] as JsonPrimitive).content,
- checksum = (row["checksum"] as JsonPrimitive).content.toLong(),
- object_type = (row["object_type"] as JsonPrimitive).content,
- op_id = (row["op_id"] as JsonPrimitive).content,
- subkey = (row["subkey"] as JsonPrimitive).content,
- data = dataMap,
- op = op
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBatch.kt b/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBatch.kt
deleted file mode 100644
index 671298c5..00000000
--- a/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBatch.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package co.powersync.kotlin.bucket
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class SyncDataBatch(val buckets: Array)
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBucket.kt b/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBucket.kt
deleted file mode 100644
index 8371b323..00000000
--- a/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBucket.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package co.powersync.kotlin.bucket
-
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonArray
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.JsonPrimitive
-
-@Serializable
-data class SyncDataBucketJSON(
- val bucket: String,
- val has_more: Boolean, // TODO is this optional?
- val after: String, // TODO is this optional?
- val next_after: String,// TODO is this optional?
- val data: Array
-)
-
-@Serializable
-data class SyncDataBucket(
-
- val bucket: String,
- val data: Array,
- /**
- * True if the response does not contain all the data for this bucket, and another request must be made.
- */
- val has_more: Boolean = false,
- /**
- * The `after` specified in the request.
- */
- val after: String ?= null,
- /**
- * Use this for the next request.
- */
- val next_after: String ?= null
-
-
-) {
- companion object {
- fun fromRow(row: JsonObject): SyncDataBucket {
- val data = (row["data"] as JsonArray).map { i -> OplogEntry.fromRow(i as JsonObject) }
- .toTypedArray()
-
- return SyncDataBucket(
- bucket = (row["bucket"] as JsonPrimitive).content,
- data = data,
- after = (row["after"] as JsonPrimitive).content,
- has_more = (row["has_more"] as JsonPrimitive).content.toBoolean(),
- next_after = (row["next_after"] as JsonPrimitive).content
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/db/Column.kt b/app/src/main/java/co/powersync/kotlin/db/Column.kt
deleted file mode 100644
index e5620ddb..00000000
--- a/app/src/main/java/co/powersync/kotlin/db/Column.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package co.powersync.kotlin.db
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-enum class ColumnType {
- TEXT,
- INTEGER,
- REAL
-}
-
-@Serializable
-class Column (
- val name: String,
- val type: ColumnType?
-)
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/db/Index.kt b/app/src/main/java/co/powersync/kotlin/db/Index.kt
deleted file mode 100644
index dbc47250..00000000
--- a/app/src/main/java/co/powersync/kotlin/db/Index.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package co.powersync.kotlin.db
-
-import kotlinx.serialization.EncodeDefault
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.Serializable
-
-@Serializable
-class Index @OptIn(ExperimentalSerializationApi::class) constructor(
- val name: String,
- @EncodeDefault val columns: Array = arrayOf()
-)
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/db/IndexedColumn.kt b/app/src/main/java/co/powersync/kotlin/db/IndexedColumn.kt
deleted file mode 100644
index 206b4435..00000000
--- a/app/src/main/java/co/powersync/kotlin/db/IndexedColumn.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package co.powersync.kotlin.db
-
-import kotlinx.serialization.EncodeDefault
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.Serializable
-
-@Serializable
-class IndexedColumn @OptIn(ExperimentalSerializationApi::class) constructor(
- val name: String,
- @EncodeDefault val ascending: Boolean = true,
- @EncodeDefault val type: ColumnType = ColumnType.TEXT
- // TODO the type needs to come from the actual listed column e.g.
- // toJSON(table: Table) {
- // return {
- // ...
- // type: table.columns.find((column) => column.name === this.name)?.type ?? ColumnType.TEXT
- // };
- // }
-)
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/db/Schema.kt b/app/src/main/java/co/powersync/kotlin/db/Schema.kt
deleted file mode 100644
index 1224b80e..00000000
--- a/app/src/main/java/co/powersync/kotlin/db/Schema.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package co.powersync.kotlin.db
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class Schema(val tables:Array)
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/db/Table.kt b/app/src/main/java/co/powersync/kotlin/db/Table.kt
deleted file mode 100644
index 6349a58a..00000000
--- a/app/src/main/java/co/powersync/kotlin/db/Table.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package co.powersync.kotlin.db
-
-import kotlinx.serialization.EncodeDefault
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.Serializable
-
-@Serializable
-class Table @OptIn(ExperimentalSerializationApi::class) constructor(
- val name: String,
- val columns: Array,
- @EncodeDefault val indexes: Array = arrayOf(),
- @EncodeDefault val localOnly: Boolean = false,
- @EncodeDefault val insertOnly: Boolean = false,
-)
\ No newline at end of file
diff --git a/app/src/main/java/co/powersync/kotlin/streaming_sync/streaming-sync-types.kt b/app/src/main/java/co/powersync/kotlin/streaming_sync/streaming-sync-types.kt
deleted file mode 100644
index 33cb656f..00000000
--- a/app/src/main/java/co/powersync/kotlin/streaming_sync/streaming-sync-types.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package co.powersync.kotlin.streaming_sync
-
-import co.powersync.kotlin.bucket.BucketChecksum
-import co.powersync.kotlin.bucket.SyncDataBucketJSON
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonObject
-
-data class StreamingSyncDataJSON(
- val data: SyncDataBucketJSON
-)
-
-data class StreamingSyncCheckpointDiff(
- val last_op_id: String,
- val updated_buckets: Array,
- val removed_buckets: Array,
- val write_checkpoint: String
-)
-
-@Serializable
-data class BucketRequest(
- val name: String,
-
- /**
- * Base-10 number. Sync all data from this bucket with op_id > after.
- */
- val after: String
-)
-
-@Serializable
-data class StreamingSyncRequest(
- /**
- * Existing bucket states.
- */
- val buckets: Array?,
-
- /**
- * If specified, limit the response to only include these buckets.
- */
- val only: Array? = null,
-
- /**
- * Whether or not to compute a checksum for each checkpoint
- */
- val include_checksum: Boolean
-)
-
-
-fun isStreamingSyncData(obj: JsonObject): Boolean {
- return obj.containsKey("data")
-}
-
-fun isStreamingKeepalive(obj: JsonObject): Boolean {
- return obj.containsKey("token_expires_in")
-}
-
-fun isStreamingSyncCheckpoint(obj: JsonObject): Boolean {
- return obj.containsKey("checkpoint")
-}
-
-fun isStreamingSyncCheckpointComplete(obj: JsonObject): Boolean {
- return obj.containsKey("checkpoint_complete")
-}
-
-fun isStreamingSyncCheckpointDiff(obj: JsonObject): Boolean {
- return obj.containsKey("checkpoint_diff")
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/notessqlite/EmployeeAdapter.kt b/app/src/main/java/com/example/notessqlite/EmployeeAdapter.kt
deleted file mode 100644
index 04c42b4a..00000000
--- a/app/src/main/java/com/example/notessqlite/EmployeeAdapter.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.example.notessqlite
-
-import android.graphics.BitmapFactory
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView
-
-class EmployeeAdapter(private val employeeList: List) : RecyclerView.Adapter() {
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmployeeViewHolder {
- val view = LayoutInflater.from(parent.context).inflate(R.layout.item_employee, parent, false)
- return EmployeeViewHolder(view)
- }
-
- override fun onBindViewHolder(holder: EmployeeViewHolder, position: Int) {
- val employee = employeeList[position]
- holder.bind(employee)
- }
-
- override fun getItemCount(): Int = employeeList.size
-
- class EmployeeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
-
- fun bind(employee: EmployeeDatabase.Employee) {
- itemView.findViewById(R.id.tv_employee_name).text = employee.name
- itemView.findViewById(R.id.tv_employee_code).text = employee.code
-
- itemView.findViewById(R.id.iv_employee_image)
- .setImageBitmap(BitmapFactory.decodeByteArray(employee.image, 0, employee.image.size))
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/notessqlite/EmployeeDatabase.kt b/app/src/main/java/com/example/notessqlite/EmployeeDatabase.kt
deleted file mode 100644
index b12fe0d8..00000000
--- a/app/src/main/java/com/example/notessqlite/EmployeeDatabase.kt
+++ /dev/null
@@ -1,146 +0,0 @@
-package com.example.notessqlite
-
-import android.content.Context
-import co.powersync.kotlin.DatabaseHelper
-import co.powersync.kotlin.PowerSyncDatabase
-import co.powersync.kotlin.SupaBaseConnector
-import co.powersync.kotlin.db.Column
-import co.powersync.kotlin.db.ColumnType
-import co.powersync.kotlin.db.Index
-import co.powersync.kotlin.db.IndexedColumn
-import co.powersync.kotlin.db.Schema
-import co.powersync.kotlin.db.Table
-import kotlinx.coroutines.runBlocking
-
-class EmployeeDatabase(context: Context) {
-
- companion object {
- private val SCHEMA: Schema = Schema( tables = arrayOf(
- Table(
- name = "todos",
- columns = arrayOf(
- Column(name = "list_id", type = ColumnType.TEXT ),
- Column(name = "created_at", type = ColumnType.TEXT ),
- Column(name = "completed_at", type = ColumnType.TEXT ),
- Column(name = "description", type = ColumnType.TEXT ),
- Column(name = "completed", type = ColumnType.INTEGER ),
- Column(name = "created_by", type = ColumnType.TEXT ),
- Column(name = "completed_by", type = ColumnType.TEXT)
- ),
- indexes = arrayOf(
- Index(
- name = "list",
- columns= arrayOf(
- IndexedColumn(name = "list_id")
- ))
- )
- ),
- Table(
- name= "lists",
- columns = arrayOf(
- Column(name = "created_at", type = ColumnType.TEXT),
- Column(name = "name", type = ColumnType.TEXT),
- Column(name = "owner_id", type = ColumnType.TEXT)
- )
- )
- )
- )
- }
-
- private val databaseHelper = DatabaseHelper(context)
- private val supaBaseClient = SupaBaseConnector();
- private val powerSyncDatabase = PowerSyncDatabase(
- dbHelper = databaseHelper,
- schema = SCHEMA,
- connector = supaBaseClient
- );
-
- fun init(){
- runBlocking {
- powerSyncDatabase.connect();
- }
- }
-
-// fun addEmployee(employee: Employee){
-// insert(employee.name, employee.code, employee.image);
-// }
-
-// fun getAllEmployees(): List{
-//// return getAll()
-// }
-
-// fun insert(name: String, code: String, image: ByteArray) {
-// // get the writable database
-// val db = databaseHelper.writableDatabase
-//
-// // create the ContentValues object
-// val values = ContentValues().apply{
-// put("employee_name", name)
-// put("employee_code", code)
-// put("employee_image", image)
-// }
-//
-// // insert the data into the table
-// db.insert("employee", null, values)
-//
-// // close the database connection
-// db.close()
-// }
-
-// @SuppressLint("Range")
-// fun getAll(): List {
-// val list = mutableListOf()
-//
-// // get the readable database
-// val db = databaseHelper.readableDatabase
-//
-// // select all data from the table
-// val cursor = db.rawQuery("SELECT * FROM employee", null)
-//
-// // iterate through the cursor and add the data to the list
-// while (cursor.moveToNext()) {
-// val name = cursor.getString(cursor.getColumnIndex("employee_name"))
-// val code = cursor.getString(cursor.getColumnIndex("employee_code"))
-// val image = cursor.getBlob(cursor.getColumnIndex("employee_image"))
-// list.add(Employee(name, code, image))
-// }
-//
-// // close the cursor and database connection
-// cursor.close()
-// db.close()
-//
-// return list
-// }
-
-// fun update(id: Int, name: String, code: String, image: ByteArray) {
-// // get the writable database
-// val db = databaseHelper.writableDatabase
-//
-// // create the ContentValues object
-// val values = ContentValues().apply {
-// put("employee_name", name)
-// put("employee_code", code)
-// put("employee_image", image)
-// }
-//
-// // update the data in the table
-// db.update("employee", values, "id = ?", arrayOf(id.toString()))
-//
-// // close the database connection
-// db.close()
-// }
-
-// fun delete(id: Int) {
-// // get the writable database
-// val db = databaseHelper.writableDatabase
-//
-// // delete the data from the table
-// db.delete("employee", "id = ?", arrayOf(id.toString()))
-//
-// // close the database connection
-// db.close()
-// }
-
- data class Employee(val name: String, val code: String, val image: ByteArray)
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/notessqlite/MainActivity.kt b/app/src/main/java/com/example/notessqlite/MainActivity.kt
deleted file mode 100644
index 347279c4..00000000
--- a/app/src/main/java/com/example/notessqlite/MainActivity.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package com.example.notessqlite
-
-import android.app.Activity
-import android.graphics.Bitmap
-import android.graphics.drawable.BitmapDrawable
-import android.os.Bundle
-import androidx.core.content.ContextCompat
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import java.io.ByteArrayOutputStream
-
-class MainActivity : Activity() {
-
- private lateinit var employeeRecyclerView: RecyclerView
- private lateinit var employeeAdapter: EmployeeAdapter
-
- private lateinit var database: EmployeeDatabase
-
-// private lateinit var nativeSqliteSDK: NativeSqliteSDK;
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_main)
-
-// nativeSqliteSDK = NativeSqliteSDK();
-// nativeSqliteSDK.initialize();
-
- employeeRecyclerView = findViewById(R.id.rv_employee_list)
-
- employeeAdapter = EmployeeAdapter(emptyList())
- employeeRecyclerView.adapter = employeeAdapter
-
- database = EmployeeDatabase(this)
- database.init()
-
- // Add sample data to the database
- //database.addEmployee(EmployeeDatabase.Employee("John Smith", "JNS001", getByteArrayFromDrawable(R.drawable.employee1)))
- //database.addEmployee(EmployeeDatabase.Employee("Jane Doe", "JDO002", getByteArrayFromDrawable(R.drawable.employee2)))
- //database.addEmployee(EmployeeDatabase.Employee("Bob Johnson", "BJH003", getByteArrayFromDrawable(R.drawable.employee3)))
-
- // Get all employees from the database and display them in the RecyclerView
-// val employeeList = database.getAllEmployees()
-// employeeAdapter = EmployeeAdapter(employeeList)
- employeeRecyclerView.adapter = employeeAdapter
- employeeRecyclerView.layoutManager = LinearLayoutManager(this)
-
- println("Done with setup")
- }
-
- // Helper function to convert a drawable resource to a byte array
- private fun getByteArrayFromDrawable(drawableId: Int): ByteArray {
- val drawable = ContextCompat.getDrawable(this, drawableId) ?: throw IllegalArgumentException("Drawable not found")
- val bitmap = (drawable as BitmapDrawable).bitmap
- val stream = ByteArrayOutputStream()
- bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
- return stream.toByteArray()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/notessqlite/ui/theme/Color.kt b/app/src/main/java/com/example/notessqlite/ui/theme/Color.kt
deleted file mode 100644
index fb8b4596..00000000
--- a/app/src/main/java/com/example/notessqlite/ui/theme/Color.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.example.notessqlite.ui.theme
-
-import androidx.compose.ui.graphics.Color
-
-val Purple80 = Color(0xFFD0BCFF)
-val PurpleGrey80 = Color(0xFFCCC2DC)
-val Pink80 = Color(0xFFEFB8C8)
-
-val Purple40 = Color(0xFF6650a4)
-val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/notessqlite/ui/theme/Theme.kt b/app/src/main/java/com/example/notessqlite/ui/theme/Theme.kt
deleted file mode 100644
index f61f882c..00000000
--- a/app/src/main/java/com/example/notessqlite/ui/theme/Theme.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.example.notessqlite.ui.theme
-
-import android.app.Activity
-import android.os.Build
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.SideEffect
-import androidx.compose.ui.graphics.toArgb
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalView
-import androidx.core.view.WindowCompat
-
-private val DarkColorScheme = darkColorScheme(
- primary = Purple80,
- secondary = PurpleGrey80,
- tertiary = Pink80
-)
-
-private val LightColorScheme = lightColorScheme(
- primary = Purple40,
- secondary = PurpleGrey40,
- tertiary = Pink40
-
- /* Other default colors to override
- background = Color(0xFFFFFBFE),
- surface = Color(0xFFFFFBFE),
- onPrimary = Color.White,
- onSecondary = Color.White,
- onTertiary = Color.White,
- onBackground = Color(0xFF1C1B1F),
- onSurface = Color(0xFF1C1B1F),
- */
-)
-
-@Composable
-fun NotesSQLiteTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
- // Dynamic color is available on Android 12+
- dynamicColor: Boolean = true,
- content: @Composable () -> Unit
-) {
- val colorScheme = when {
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- val context = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
- }
-
- darkTheme -> DarkColorScheme
- else -> LightColorScheme
- }
- val view = LocalView.current
- if (!view.isInEditMode) {
- SideEffect {
- val window = (view.context as Activity).window
- window.statusBarColor = colorScheme.primary.toArgb()
- WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
- }
- }
-
- MaterialTheme(
- colorScheme = colorScheme,
- typography = Typography,
- content = content
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/notessqlite/ui/theme/Type.kt b/app/src/main/java/com/example/notessqlite/ui/theme/Type.kt
deleted file mode 100644
index 711fe129..00000000
--- a/app/src/main/java/com/example/notessqlite/ui/theme/Type.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.example.notessqlite.ui.theme
-
-import androidx.compose.material3.Typography
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.sp
-
-// Set of Material typography styles to start with
-val Typography = Typography(
- bodyLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 16.sp,
- lineHeight = 24.sp,
- letterSpacing = 0.5.sp
- )
- /* Other default text styles to override
- titleLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 22.sp,
- lineHeight = 28.sp,
- letterSpacing = 0.sp
- ),
- labelSmall = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 11.sp,
- lineHeight = 16.sp,
- letterSpacing = 0.5.sp
- )
- */
-)
\ No newline at end of file
diff --git a/app/src/main/res/drawable/employee1.png b/app/src/main/res/drawable/employee1.png
deleted file mode 100644
index bf29b72d..00000000
Binary files a/app/src/main/res/drawable/employee1.png and /dev/null differ
diff --git a/app/src/main/res/drawable/employee2.png b/app/src/main/res/drawable/employee2.png
deleted file mode 100644
index 52b65a1a..00000000
Binary files a/app/src/main/res/drawable/employee2.png and /dev/null differ
diff --git a/app/src/main/res/drawable/employee3.png b/app/src/main/res/drawable/employee3.png
deleted file mode 100644
index 969a46ef..00000000
Binary files a/app/src/main/res/drawable/employee3.png and /dev/null differ
diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml
deleted file mode 100644
index 284624f4..00000000
--- a/app/src/main/res/drawable/ic_person.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_person2.xml b/app/src/main/res/drawable/ic_person2.xml
deleted file mode 100644
index afab3920..00000000
--- a/app/src/main/res/drawable/ic_person2.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_person3.xml b/app/src/main/res/drawable/ic_person3.xml
deleted file mode 100644
index fd5544a9..00000000
--- a/app/src/main/res/drawable/ic_person3.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_person4.xml b/app/src/main/res/drawable/ic_person4.xml
deleted file mode 100644
index 2c464330..00000000
--- a/app/src/main/res/drawable/ic_person4.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index 879879bf..00000000
--- a/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_employee.xml b/app/src/main/res/layout/item_employee.xml
deleted file mode 100644
index a3eedfda..00000000
--- a/app/src/main/res/layout/item_employee.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
deleted file mode 100644
index c209e78e..00000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
deleted file mode 100644
index b2dfe3d1..00000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
deleted file mode 100644
index 4f0f1d64..00000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
deleted file mode 100644
index 62b611da..00000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
deleted file mode 100644
index 948a3070..00000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
deleted file mode 100644
index 1b9a6956..00000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
deleted file mode 100644
index 28d4b77f..00000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9287f508..00000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
deleted file mode 100644
index aa7d6427..00000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9126ae37..00000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
deleted file mode 100644
index f8c6127d..00000000
--- a/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
- #FF000000
- #FFFFFFFF
-
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
deleted file mode 100644
index a9898618..00000000
--- a/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Notes SQLite
-
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
deleted file mode 100644
index c1fca5b6..00000000
--- a/app/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
deleted file mode 100644
index fa0f996d..00000000
--- a/app/src/main/res/xml/backup_rules.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
deleted file mode 100644
index 9ee9997b..00000000
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/test/java/com/example/notessqlite/ExampleUnitTest.kt b/app/src/test/java/com/example/notessqlite/ExampleUnitTest.kt
deleted file mode 100644
index af9936fa..00000000
--- a/app/src/test/java/com/example/notessqlite/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.example.notessqlite
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index b4cf450b..00a0366c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,5 +1,55 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id("com.android.application") version "8.1.2" apply false
- id("org.jetbrains.kotlin.android") version "1.8.10" apply false
+ alias(libs.plugins.jetbrainsCompose) apply false
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.androidLibrary) apply false
+ alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.cocoapods) apply false
+ alias(libs.plugins.kmmbridge) apply false
+ alias(libs.plugins.skie) apply false
+ alias(libs.plugins.kotlin.jvm) apply false
+ alias(libs.plugins.sqldelight) apply false
+ alias(libs.plugins.grammarKitComposer) apply false
+ alias(libs.plugins.mavenPublishPlugin) apply false
+ alias(libs.plugins.downloadPlugin) apply false
+}
+
+
+allprojects {
+ repositories {
+ mavenCentral()
+ google()
+ maven("https://jitpack.io")
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ maven("https://www.jetbrains.com/intellij-repository/releases")
+ 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/")
+ }
+
+
+ configurations.configureEach {
+ exclude(group = "com.jetbrains.rd")
+ exclude(group = "com.github.jetbrains", module = "jetCheck")
+ exclude(group = "com.jetbrains.intellij.platform", module = "wsl-impl")
+ exclude(group = "org.roaringbitmap")
+ exclude(group = "com.jetbrains.infra")
+ exclude(group = "org.jetbrains.teamcity")
+ exclude(group = "org.roaringbitmap")
+ exclude(group = "ai.grazie.spell")
+ exclude(group = "ai.grazie.model")
+ exclude(group = "ai.grazie.utils")
+ exclude(group = "ai.grazie.nlp")
+ }
+
+}
+subprojects {
+ val GROUP: String by project
+ val LIBRARY_VERSION: String by project
+
+ group = GROUP
+ version = LIBRARY_VERSION
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
}
\ No newline at end of file
diff --git a/connectors/README.md b/connectors/README.md
new file mode 100644
index 00000000..912d35ac
--- /dev/null
+++ b/connectors/README.md
@@ -0,0 +1,15 @@
+# PowerSync Backend Connectors
+
+Convenience implementations of backend connectors that provides the connection between your application backend and the PowerSync managed database.
+
+It is used to:
+1. Retrieve a token to connect to the PowerSync service.
+2. Apply local changes on your backend application server (and from there, to Postgres).
+
+## Supported Connectors
+
+### Supabase
+
+A basic implementation of a PowerSync Backend Connector for Supabase, that serves as getting started example.
+
+See a step-by-step tutorial for connecting to Supabase, [here](https://docs.powersync.com/integration-guides/supabase-+-powersync).
\ No newline at end of file
diff --git a/connectors/supabase/build.gradle.kts b/connectors/supabase/build.gradle.kts
new file mode 100644
index 00000000..39c57d4f
--- /dev/null
+++ b/connectors/supabase/build.gradle.kts
@@ -0,0 +1,40 @@
+import com.powersync.plugins.sonatype.setupGithubRepository
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.kotlinSerialization)
+ alias(libs.plugins.androidLibrary)
+ id("com.powersync.plugins.sonatype")
+}
+
+kotlin {
+ androidTarget {
+ publishLibraryVariants("release", "debug")
+ }
+
+ iosX64()
+ iosArm64()
+ iosSimulatorArm64()
+
+ sourceSets {
+ commonMain.dependencies {
+ api(project(":core"))
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.supabase.client)
+ implementation(libs.supabase.gotrue)
+ }
+ }
+}
+
+android {
+ namespace = "com.powersync.connector.supabase"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+ defaultConfig {
+ minSdk = libs.versions.android.minSdk.get().toInt()
+ }
+ kotlin {
+ jvmToolchain(17)
+ }
+}
+
+setupGithubRepository()
\ No newline at end of file
diff --git a/connectors/supabase/gradle.properties b/connectors/supabase/gradle.properties
new file mode 100644
index 00000000..bf07c156
--- /dev/null
+++ b/connectors/supabase/gradle.properties
@@ -0,0 +1,3 @@
+POM_ARTIFACT_ID=connector-supabase
+POM_NAME=Supabase PowerSync Backend Connector
+POM_DESCRIPTION=Connector to connect to PowerSync Backends using Supabase.
\ No newline at end of file
diff --git a/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt b/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt
new file mode 100644
index 00000000..10cd6b24
--- /dev/null
+++ b/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt
@@ -0,0 +1,123 @@
+package com.powersync.connector.supabase
+
+import com.powersync.PowerSyncDatabase
+import com.powersync.connectors.PowerSyncBackendConnector
+import com.powersync.connectors.PowerSyncCredentials
+import com.powersync.db.crud.CrudEntry
+import com.powersync.db.crud.UpdateType
+import io.github.jan.supabase.SupabaseClient
+import io.github.jan.supabase.createSupabaseClient
+import io.github.jan.supabase.gotrue.Auth
+import io.github.jan.supabase.gotrue.auth
+import io.github.jan.supabase.gotrue.providers.builtin.Email
+import io.github.jan.supabase.postgrest.Postgrest
+import io.github.jan.supabase.postgrest.from
+
+/**
+ * Get a Supabase token to authenticate against the PowerSync instance.
+ */
+class SupabaseConnector(
+ val supabaseUrl: String,
+ val supabaseKey: String,
+ val powerSyncEndpoint: String,
+) : PowerSyncBackendConnector() {
+
+ private var loggedIn: Boolean = false
+ private val supabaseClient: SupabaseClient = createClient()
+
+ private fun createClient(): SupabaseClient {
+ val client = createSupabaseClient(
+ supabaseUrl = supabaseUrl,
+ supabaseKey = supabaseKey
+ ) {
+ install(Auth)
+ install(Postgrest)
+ }
+
+ return client
+ }
+
+ suspend fun login(email: String, password: String) {
+ supabaseClient.auth.signInWith(Email) {
+ this.email = email
+ this.password = password
+ }
+ this.loggedIn = true
+ fetchCredentials()
+ }
+
+ /**
+ * Get credentials for PowerSync.
+ */
+ override suspend fun fetchCredentials(): PowerSyncCredentials {
+ if (!loggedIn) {
+ throw Exception("Not logged in")
+ }
+
+ // Use Supabase token for PowerSync
+ val session = supabaseClient.auth.currentSessionOrNull()
+ ?: throw Exception("Could not fetch Supabase credentials");
+
+ if (session.user == null) {
+ throw Exception("No user data")
+ }
+
+ // userId and expiresAt are for debugging purposes only
+ return PowerSyncCredentials(
+ endpoint = powerSyncEndpoint,
+ token = session.accessToken, // Use the access token to authenticate against PowerSync
+ expiresAt = session.expiresAt,
+ userId = session.user!!.id
+ );
+ }
+
+ /**
+ * Upload local changes to the app backend (in this case Supabase).
+ *
+ * This function is called whenever there is data to upload, whether the device is online or offline.
+ * If this call throws an error, it is retried periodically.
+ */
+ override suspend fun uploadData(database: PowerSyncDatabase) {
+
+ val transaction = database.getNextCrudTransaction() ?: return;
+
+ var lastEntry: CrudEntry? = null;
+ try {
+
+ for (entry in transaction.crud) {
+ lastEntry = entry;
+
+ val table = supabaseClient.from(entry.table)
+ when (entry.op) {
+ UpdateType.PUT -> {
+ val data = entry.opData?.toMutableMap() ?: mutableMapOf()
+ data["id"] = entry.id
+ table.upsert(data)
+ }
+
+ UpdateType.PATCH -> {
+ table.update(entry.opData!!) {
+ filter {
+ eq("id", entry.id)
+ }
+ }
+ }
+
+ UpdateType.DELETE -> {
+ table.delete {
+ filter {
+ eq("id", entry.id)
+ }
+ }
+ }
+ }
+ }
+
+ transaction.complete(null);
+
+ } catch (e: Exception) {
+ println("Data upload error - retrying last entry: ${lastEntry!!}, $e")
+ throw e
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/README.md b/core/README.md
new file mode 100644
index 00000000..cd6f5440
--- /dev/null
+++ b/core/README.md
@@ -0,0 +1,19 @@
+# PowerSync core module
+
+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:
+
+- `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.
+
+## 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.
+
+With the Alpha release, the PowerSync core module does not support integrating with SQLDelight from client applications.
\ No newline at end of file
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
new file mode 100644
index 00000000..305eaac0
--- /dev/null
+++ b/core/build.gradle.kts
@@ -0,0 +1,176 @@
+import de.undercouch.gradle.tasks.download.Download
+import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
+import com.powersync.plugins.sonatype.setupGithubRepository
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.kotlinSerialization)
+ alias(libs.plugins.androidLibrary)
+ alias(libs.plugins.sqldelight)
+ alias(libs.plugins.mavenPublishPlugin)
+ alias(libs.plugins.downloadPlugin)
+ id("com.powersync.plugins.sonatype")
+}
+
+val sqliteVersion = "3450000"
+val sqliteReleaseYear = "2024"
+
+val sqliteSrcFolder =
+ project.layout.buildDirectory.dir("interop/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 cFile = sqliteSrcFolder.file("sqlite3.c").asFile
+ val defFile = sqliteSrcFolder.file("sqlite3.def").asFile
+
+ doFirst {
+ defFile.writeText(
+ """
+ package = com.powersync.sqlite3
+ ---
+
+ """.trimIndent() + cFile.readText()
+ )
+ }
+ outputs.files(defFile)
+}
+
+kotlin {
+ androidTarget {
+ publishLibraryVariants("release", "debug")
+ }
+
+ iosX64()
+ iosArm64()
+ iosSimulatorArm64()
+
+ targets.withType {
+ compilations.getByName("main") {
+ cinterops.create("sqlite") {
+ val cInteropTask = tasks[interopProcessingTaskName]
+ cInteropTask.dependsOn(buildCInteropDef)
+ defFile = buildCInteropDef.get().outputs.files.singleFile
+ }
+ cinterops.create("powersync-sqlite-core")
+ }
+ }
+
+ sourceSets {
+ all {
+ languageSettings {
+ optIn("kotlinx.cinterop.ExperimentalForeignApi")
+ }
+ }
+ commonMain.dependencies {
+ implementation(libs.uuid)
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.contentnegotiation)
+ implementation(libs.ktor.serialization.json)
+ implementation(libs.kotlinx.io)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.datetime)
+ implementation(libs.stately.concurrency)
+ implementation(libs.bundles.sqldelight)
+ }
+ androidMain.dependencies {
+ implementation(libs.powersync.sqlite.core)
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.requery.sqlite.android)
+ implementation(libs.sqldelight.driver.android)
+ }
+ iosMain.dependencies {
+ implementation(libs.ktor.client.ios)
+ implementation(libs.sqldelight.driver.ios)
+ }
+
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
+ }
+ }
+}
+
+android {
+ kotlin {
+ jvmToolchain(17)
+ }
+
+ namespace = "com.powersync"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+ defaultConfig {
+ minSdk = libs.versions.android.minSdk.get().toInt()
+
+ externalNativeBuild {
+ cmake {
+ arguments.addAll(
+ listOf(
+ "-DSQLITE3_SRC_DIR=${sqliteSrcFolder.asFile.absolutePath}"
+ )
+ )
+ }
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ path = project.file("src/androidMain/cpp/CMakeLists.txt")
+ }
+ }
+}
+
+
+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)
+ }
+}
+
+sqldelight {
+ databases {
+ create("PsDatabase") {
+ packageName.set("com.powersync.db")
+ generateAsync = true
+ dialect(project(":dialect"))
+ }
+ }
+ linkSqlite = true
+}
+
+setupGithubRepository()
+
diff --git a/core/gradle.properties b/core/gradle.properties
new file mode 100644
index 00000000..7a48ea52
--- /dev/null
+++ b/core/gradle.properties
@@ -0,0 +1,3 @@
+POM_ARTIFACT_ID=core
+POM_NAME=PowerSync Core SDK
+POM_DESCRIPTION=A PowerSync Kotlin Multiplatform SDK.
diff --git a/core/src/androidMain/cpp/CMakeLists.txt b/core/src/androidMain/cpp/CMakeLists.txt
new file mode 100644
index 00000000..648fa833
--- /dev/null
+++ b/core/src/androidMain/cpp/CMakeLists.txt
@@ -0,0 +1,25 @@
+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
+ 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/cpp/sqlite_bindings.cpp b/core/src/androidMain/cpp/sqlite_bindings.cpp
new file mode 100644
index 00000000..81ccb1df
--- /dev/null
+++ b/core/src/androidMain/cpp/sqlite_bindings.cpp
@@ -0,0 +1,91 @@
+#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 jint
+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);
+ }
+}
+
+jint powersync_init(sqlite3 *db, char **pzErrMsg,
+ const sqlite3_api_routines *pApi) {
+
+ 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/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt
new file mode 100644
index 00000000..71fb7e8c
--- /dev/null
+++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt
@@ -0,0 +1,78 @@
+package com.powersync
+
+import android.content.Context
+import androidx.sqlite.db.SupportSQLiteDatabase
+import app.cash.sqldelight.async.coroutines.synchronous
+import app.cash.sqldelight.driver.android.AndroidSqliteDriver
+import com.powersync.db.internal.PsInternalSchema
+import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
+import io.requery.android.database.sqlite.SQLiteCustomExtension
+import kotlinx.coroutines.CoroutineScope
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+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 ->
+ if (success) {
+ driver.fireTableUpdates()
+ } else {
+ driver.clearTableUpdates()
+ }
+ }
+ }
+
+ actual fun createDriver(
+ scope: CoroutineScope,
+ dbFilename: String,
+ ): PsSqlDriver {
+ val schema = PsInternalSchema.synchronous()
+
+ 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)
+ }
+ }
+ ))
+ setupSqliteBinding()
+ return this.driver as PsSqlDriver
+ }
+
+
+ companion object {
+ init {
+ System.loadLibrary("powersync-sqlite")
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt
new file mode 100644
index 00000000..9b6c61e7
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt
@@ -0,0 +1,12 @@
+package com.powersync
+
+import kotlinx.coroutines.CoroutineScope
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+expect class DatabaseDriverFactory {
+
+ fun createDriver(
+ scope: CoroutineScope,
+ dbFilename: String
+ ): PsSqlDriver
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncBuilder.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncBuilder.kt
new file mode 100644
index 00000000..d7c84b96
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncBuilder.kt
@@ -0,0 +1,56 @@
+package com.powersync
+
+import com.powersync.db.PowerSyncDatabaseImpl
+import com.powersync.db.schema.Schema
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+
+interface PowerSyncBuilder {
+
+ fun build(): PowerSyncDatabase
+
+ /**
+ * By default [PowerSyncDatabase] will open a global scope for management of shared processes, if instead you'd like to control
+ * the scope that sharing/multicasting happens in you can pass a @param [scope]
+ */
+ fun scope(scope: CoroutineScope): PowerSyncBuilder
+
+ companion object {
+
+ const val DEFAULT_DB_FILENAME = "powersync.db"
+ fun from(factory: DatabaseDriverFactory, schema: Schema): PowerSyncBuilder {
+ return from(factory, schema, DEFAULT_DB_FILENAME)
+ }
+
+ fun from(
+ factory: DatabaseDriverFactory,
+ schema: Schema,
+ dbFilename: String
+ ): PowerSyncBuilder {
+ return PowerSyncBuilderImpl(factory, schema, dbFilename)
+ }
+ }
+}
+
+internal class PowerSyncBuilderImpl(
+ private val factory: DatabaseDriverFactory,
+ private val schema: Schema,
+ private val dbFilename: String
+) : PowerSyncBuilder {
+
+ private var scope: CoroutineScope? = null
+
+ override fun scope(scope: CoroutineScope): PowerSyncBuilder {
+ this.scope = scope
+ return this
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ override fun build(): PowerSyncDatabase = PowerSyncDatabaseImpl(
+ schema = schema,
+ factory = factory,
+ dbFilename = dbFilename,
+ scope = scope ?: GlobalScope
+ )
+}
diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt
new file mode 100644
index 00000000..d05238bc
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt
@@ -0,0 +1,81 @@
+package com.powersync
+
+import app.cash.sqldelight.db.SqlDriver
+import com.powersync.connectors.PowerSyncBackendConnector
+import com.powersync.db.ReadQueries
+import com.powersync.db.WriteQueries
+import com.powersync.db.crud.CrudBatch
+import com.powersync.db.crud.CrudTransaction
+import com.powersync.sync.SyncStatus
+import com.powersync.sync.SyncStream
+
+/**
+ * A PowerSync managed database.
+ *
+ * Use one instance per database file.
+ *
+ * Use [PowerSyncDatabase.connect] to connect to the PowerSync service, to keep the local database in sync with the remote database.
+ *
+ * All changes to local tables are automatically recorded, whether connected or not. Once connected, the changes are uploaded.
+ */
+interface PowerSyncDatabase : ReadQueries, WriteQueries {
+
+ /**
+ * The current sync status.
+ */
+ val currentStatus: SyncStatus
+
+ var syncStream: SyncStream?
+
+ /**
+ * Connect to the PowerSync service, and keep the databases in sync.
+ *
+ * The connection is automatically re-opened if it fails for any reason.
+ *
+ * TODO: Status changes are reported on [statusStream].
+ */
+
+ suspend fun connect(connector: PowerSyncBackendConnector)
+
+
+ /**
+ * Get a batch of crud data to upload.
+ *
+ * Returns null if there is no data to upload.
+ *
+ * Use this from the [PowerSyncBackendConnector.uploadData]` callback.
+ *
+ * Once the data have been successfully uploaded, call [CrudBatch.complete] before
+ * requesting the next batch.
+ *
+ * Use [limit] to specify the maximum number of updates to return in a single
+ * batch. Default is 100.
+ *
+ * This method does include transaction ids in the result, but does not group
+ * data by transaction. One batch may contain data from multiple transactions,
+ * and a single transaction may be split over multiple batches.
+ */
+ suspend fun getCrudBatch(limit: Int = 100): CrudBatch?
+
+
+ /**
+ * Get the next recorded transaction to upload.
+ *
+ * Returns null if there is no data to upload.
+ *
+ * Use this from the [PowerSyncBackendConnector.uploadData]` callback.
+ *
+ * Once the data have been successfully uploaded, call [CrudTransaction.complete] before
+ * requesting the next transaction.
+ *
+ * Unlike [getCrudBatch], this only returns data from a single transaction at a time.
+ * All data for the transaction is loaded into memory.
+ */
+
+ suspend fun getNextCrudTransaction(): CrudTransaction?
+
+ /**
+ * Convenience method to get the current version of PowerSync.
+ */
+ suspend fun getPowerSyncVersion(): String
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt b/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt
new file mode 100644
index 00000000..dbd75009
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/PsSqlDriver.kt
@@ -0,0 +1,48 @@
+package com.powersync
+
+import app.cash.sqldelight.db.SqlDriver
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+class PsSqlDriver(private val driver: SqlDriver, private val scope: CoroutineScope) :
+ SqlDriver by driver {
+ // MutableSharedFlow to emit batched table updates
+ private val tableUpdatesFlow = MutableSharedFlow>(replay = 0)
+
+ // In-memory buffer to store table names before flushing
+ private val pendingUpdates = mutableSetOf()
+
+ fun updateTable(tableName: String) {
+ pendingUpdates.add(tableName)
+ }
+
+ fun clearTableUpdates() {
+ pendingUpdates.clear()
+ }
+
+ // Flows on table updates
+ fun tableUpdates(): Flow> {
+ return tableUpdatesFlow.asSharedFlow()
+ }
+
+ // Flows on table updates containing a specific table
+ fun updatesOnTable(tableName: String): Flow {
+ return tableUpdates().filter { it.contains(tableName) }.map { }
+ }
+
+ fun fireTableUpdates() {
+ val updates = pendingUpdates.toList()
+ if (updates.isEmpty()) {
+ return;
+ }
+ scope.launch {
+ tableUpdatesFlow.emit(updates)
+ }
+ pendingUpdates.clear()
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt
new file mode 100644
index 00000000..8c3474e8
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt
@@ -0,0 +1,12 @@
+package com.powersync.bucket
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BucketChecksum(
+ val bucket: String,
+ val checksum: Int,
+ val count: Int? = null,
+ @SerialName("last_op_id") val lastOpId: String? = null
+)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt
new file mode 100644
index 00000000..07deb585
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt
@@ -0,0 +1,6 @@
+package com.powersync.bucket
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BucketRequest(val name: String, val after: String)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketState.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketState.kt
new file mode 100644
index 00000000..c5f76d76
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketState.kt
@@ -0,0 +1,13 @@
+package com.powersync.bucket
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BucketState(
+ val bucket: String,
+ @SerialName("op_id") val opId: String
+) {
+ override fun toString() = "BucketState<$bucket:$opId>"
+}
+
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt
new file mode 100644
index 00000000..cfad1be9
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt
@@ -0,0 +1,305 @@
+package com.powersync.bucket
+
+import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
+import com.powersync.db.internal.PsInternalDatabase
+import com.powersync.sync.SyncDataBatch
+import com.powersync.sync.SyncLocalDatabaseResult
+import co.touchlab.stately.concurrency.AtomicBoolean
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import com.benasher44.uuid.uuid4
+import com.powersync.db.internal.PsInternalTable
+import kotlinx.coroutines.runBlocking
+
+class BucketStorage(val db: PsInternalDatabase) {
+
+ private val tableNames: MutableSet = mutableSetOf()
+ private var hasCompletedSync = AtomicBoolean(false)
+ private var checksumCache: ChecksumCache? = null
+ private var pendingBucketDeletes = AtomicBoolean(false)
+
+ /**
+ * Count up, and do a compact on startup.
+ */
+ private var compactCounter = COMPACT_OPERATION_INTERVAL;
+
+ companion object {
+ const val MAX_OP_ID = "9223372036854775807"
+ const val COMPACT_OPERATION_INTERVAL = 1_000
+ }
+
+ init {
+ runBlocking {
+ readTableNames()
+ }
+ }
+
+ private suspend fun readTableNames() {
+ tableNames.clear()
+ // Query to get existing table names
+ val names =
+ db.getAll("SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'ps_data_*'",
+ mapper = { cursor ->
+ cursor.getString(0)!!
+ }
+ )
+
+ tableNames.addAll(names)
+ }
+
+ fun startSession() {
+ checksumCache = null;
+ }
+
+ fun getMaxOpId(): String {
+ return MAX_OP_ID;
+ }
+
+ suspend fun hasCrud(): Boolean {
+ return db.queries.hasCrud().awaitAsOneOrNull() == 1L
+ }
+
+ suspend fun updateLocalTarget(checkpointCallback: suspend () -> String): Boolean {
+ db.getOptional(
+ "SELECT target_op FROM ${PsInternalTable.BUCKETS} WHERE name = '\$local' AND target_op = ?",
+ parameters = listOf(MAX_OP_ID),
+ mapper = { cursor -> cursor.getLong(0)!! }
+ )
+ ?: // Nothing to update
+ return false
+
+ val seqBefore =
+ db.getOptional("SELECT seq FROM sqlite_sequence WHERE name = '${PsInternalTable.CRUD}'") {
+ it.getLong(0)!!
+ } ?: // Nothing to update
+ return false
+
+ val opId = checkpointCallback()
+
+ println("[BucketStorage::updateLocalTarget] Updating target to checkpoint $opId")
+
+ return db.readTransaction {
+ if (hasCrud()) {
+ println("[BucketStorage::updateLocalTarget] ps crud is not empty")
+ return@readTransaction false
+ }
+
+ val seqAfter =
+ db.getOptional("SELECT seq FROM sqlite_sequence WHERE name = '${PsInternalTable.CRUD}'") {
+ it.getLong(0)!!
+ }
+ ?: // assert isNotEmpty
+ throw AssertionError("Sqlite Sequence should not be empty")
+
+ if (seqAfter != seqBefore) {
+ // New crud data may have been uploaded since we got the checkpoint. Abort.
+ return@readTransaction false;
+ }
+
+ db.execute(
+ "UPDATE ${PsInternalTable.BUCKETS} SET target_op = ? WHERE name='\$local'",
+ listOf(opId)
+ )
+ return@readTransaction true
+ }
+ }
+
+ suspend fun saveSyncData(syncDataBatch: SyncDataBatch) {
+ db.writeTransaction {
+ val jsonString = Json.encodeToString(syncDataBatch);
+ db.execute(
+ "INSERT INTO powersync_operations(op, data) VALUES(?, ?)",
+ listOf("save", jsonString)
+ )
+ }
+ this.compactCounter += syncDataBatch.buckets.sumOf { it.data.size }
+ }
+
+ suspend fun getBucketStates(): List {
+ return db.getAll(
+ "SELECT name as bucket, cast(last_op as TEXT) as op_id FROM ${PsInternalTable.BUCKETS} WHERE pending_delete = 0",
+ mapper = { cursor ->
+ BucketState(
+ bucket = cursor.getString(0)!!,
+ opId = cursor.getString(1)!!
+ )
+ })
+ }
+
+ suspend fun removeBuckets(bucketsToDelete: List) {
+ bucketsToDelete.forEach { bucketName ->
+ deleteBucket(bucketName)
+ }
+ }
+
+
+ suspend fun deleteBucket(bucketName: String) {
+ val newName = "\$delete_${bucketName}_${uuid4()}";
+
+ db.writeTransaction {
+ db.execute(
+ "UPDATE ps_oplog SET op=${OpType.REMOVE}, data=NULL WHERE op=${OpType.PUT} AND superseded=0 AND bucket=?",
+ listOf(bucketName)
+ )
+
+ // Rename bucket
+ db.execute(
+ "UPDATE ps_oplog SET bucket=? WHERE bucket=?",
+ listOf(newName, bucketName)
+ )
+
+ db.execute("DELETE FROM ps_buckets WHERE name = ?", parameters = listOf(bucketName))
+ }
+
+ this.pendingBucketDeletes.value = true;
+ }
+
+ suspend fun hasCompletedSync(): Boolean {
+ if (hasCompletedSync.value) {
+ return true
+ }
+
+ val completedSync = db.getOptional(
+ "SELECT name, last_applied_op FROM ps_buckets WHERE last_applied_op > 0 LIMIT 1",
+ mapper = { cursor ->
+ cursor.getString(0)!!
+ })
+
+ return if (completedSync != null) {
+ hasCompletedSync.value = true
+ true
+ } else {
+ false
+ }
+ }
+
+ suspend fun syncLocalDatabase(targetCheckpoint: Checkpoint): SyncLocalDatabaseResult {
+ val result = validateChecksums(targetCheckpoint);
+
+ if (!result.checkpointValid) {
+ println("[BucketStorage::SyncLocalDatabase] Checksums failed for ${result.checkpointFailures}")
+ result.checkpointFailures?.forEach { bucketName ->
+ deleteBucket(bucketName)
+ }
+ result.ready = false
+ return result
+ }
+
+ val bucketNames = targetCheckpoint.checksums.map { it.bucket }
+
+ db.writeTransaction {
+ db.execute(
+ "UPDATE ps_buckets SET last_op = ? WHERE name IN (SELECT json_each.value FROM json_each(?))",
+ listOf(targetCheckpoint.lastOpId, Json.encodeToString(bucketNames))
+ )
+
+ if (targetCheckpoint.writeCheckpoint != null) {
+ db.execute(
+ "UPDATE ps_buckets SET last_op = ? WHERE name = '\$local'",
+ listOf(targetCheckpoint.writeCheckpoint),
+ )
+ }
+ }
+
+ val valid = updateObjectsFromBuckets(targetCheckpoint);
+
+ if (!valid) {
+ return SyncLocalDatabaseResult(
+ ready = false,
+ checkpointValid = true,
+ )
+ }
+
+ this.forceCompact()
+
+ return SyncLocalDatabaseResult(
+ ready = true,
+ )
+ }
+
+ suspend fun validateChecksums(checkpoint: Checkpoint): SyncLocalDatabaseResult {
+ val res = db.getOptional(
+ "SELECT powersync_validate_checkpoint(?) as result",
+ parameters = listOf(Json.encodeToString(checkpoint)),
+ mapper = { cursor ->
+ cursor.getString(0)!!
+ })
+ ?: //no result
+ return SyncLocalDatabaseResult(
+ ready = false,
+ checkpointValid = false,
+ )
+
+ return Json.decodeFromString(res);
+ }
+
+ /**
+ * Atomically update the local state to the current checkpoint.
+ *
+ * This includes creating new tables, dropping old tables, and copying data over from the oplog.
+ */
+ private suspend fun updateObjectsFromBuckets(checkpoint: Checkpoint): Boolean {
+ return db.writeTransaction {
+ val res = db.execute(
+ "INSERT INTO powersync_operations(op, data) VALUES(?, ?)",
+ listOf("sync_local", "")
+ )
+
+ return@writeTransaction res == 1L
+ }
+ }
+
+ suspend fun forceCompact() {
+ // Reset counter
+ this.compactCounter = COMPACT_OPERATION_INTERVAL;
+ this.pendingBucketDeletes.value = true;
+
+ this.autoCompact();
+ }
+
+
+ suspend fun autoCompact() {
+ // 1. Delete buckets
+ deletePendingBuckets();
+
+ // 2. Clear REMOVE operations, only keeping PUT ones
+ clearRemoveOps()
+ }
+
+ private suspend fun deletePendingBuckets() {
+ if (!this.pendingBucketDeletes.value) {
+ return;
+ }
+
+ db.writeTransaction {
+ db.execute(
+ "DELETE FROM ps_oplog WHERE bucket IN (SELECT name FROM ps_buckets WHERE pending_delete = 1 AND last_applied_op = last_op AND last_op >= target_op)",
+ )
+
+ db.execute(
+ "DELETE FROM ps_buckets WHERE pending_delete = 1 AND last_applied_op = last_op AND last_op >= target_op",
+ )
+ // Executed once after start-up, and again when there are pending deletes.
+ pendingBucketDeletes.value = false;
+ }
+ }
+
+ private suspend fun clearRemoveOps() {
+ if (this.compactCounter < COMPACT_OPERATION_INTERVAL) {
+ return;
+ }
+
+ db.writeTransaction {
+ db.execute(
+ "INSERT INTO powersync_operations(op, data) VALUES (?, ?)",
+ listOf("clear_remove_ops", "")
+ )
+ }
+ this.compactCounter = 0;
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ fun setTargetCheckpoint(checkpoint: Checkpoint) {
+ // No-op for now
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt b/core/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt
new file mode 100644
index 00000000..4b62c96f
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt
@@ -0,0 +1,15 @@
+package com.powersync.bucket
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Checkpoint(
+ @SerialName("last_op_id") val lastOpId: String,
+ @SerialName("buckets") val checksums: List,
+ @SerialName("write_checkpoint") val writeCheckpoint: String? = null
+) {
+ fun clone(): Checkpoint {
+ return Checkpoint(lastOpId, checksums, writeCheckpoint)
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt b/core/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt
new file mode 100644
index 00000000..a88e6b26
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt
@@ -0,0 +1,10 @@
+package com.powersync.bucket
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ChecksumCache(
+ @SerialName("last_op_id") val lostOpId: String,
+ val checksums: Map
+)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/OpType.kt b/core/src/commonMain/kotlin/com/powersync/bucket/OpType.kt
new file mode 100644
index 00000000..06c0a793
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/OpType.kt
@@ -0,0 +1,15 @@
+package com.powersync.bucket
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+enum class OpType(private val value: Int) {
+ CLEAR(1),
+ MOVE(2),
+ PUT(3),
+ REMOVE(4);
+
+ override fun toString(): String {
+ return value.toString()
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/OplogEntry.kt b/core/src/commonMain/kotlin/com/powersync/bucket/OplogEntry.kt
new file mode 100644
index 00000000..e3d4a6fe
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/OplogEntry.kt
@@ -0,0 +1,19 @@
+package com.powersync.bucket
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class OplogEntry (
+ val checksum: Long,
+ @SerialName("op_id") val opId: String,
+ @SerialName("object_id") val rowId: String? = null,
+ @SerialName("object_type") val rowType: String?= null,
+ val op: OpType? = null,
+ /**
+ * Together with rowType and rowId, this uniquely identifies a source entry per bucket in the oplog.
+ * There may be multiple source entries for a single "rowType + rowId" combination.
+ */
+ val subkey: String? = null,
+ val data: Map? = null
+)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/SqliteOp.kt b/core/src/commonMain/kotlin/com/powersync/bucket/SqliteOp.kt
new file mode 100644
index 00000000..ef07bf10
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/SqliteOp.kt
@@ -0,0 +1,3 @@
+package com.powersync.bucket
+
+data class SqliteOp(val sql: String, val args: List)
diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/WriteCheckpointResult.kt b/core/src/commonMain/kotlin/com/powersync/bucket/WriteCheckpointResult.kt
new file mode 100644
index 00000000..2c222350
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/bucket/WriteCheckpointResult.kt
@@ -0,0 +1,14 @@
+package com.powersync.bucket
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class WriteCheckpointResponse(
+ val data: WriteCheckpointData
+)
+
+@Serializable
+data class WriteCheckpointData(
+ @SerialName("write_checkpoint") val writeCheckpoint: String
+)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt
new file mode 100644
index 00000000..08632477
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt
@@ -0,0 +1,81 @@
+package com.powersync.connectors
+
+import com.powersync.PowerSyncDatabase
+import kotlinx.coroutines.async
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+
+/**
+ * Implement this to connect an app backend.
+ *
+ * The connector is responsible for:
+ * 1. Creating credentials for connecting to the PowerSync service.
+ * 2. Applying local changes against the backend application server.
+ *
+ */
+abstract class PowerSyncBackendConnector {
+ private var cachedCredentials: PowerSyncCredentials? = null
+ private var fetchRequest: Deferred? = null
+
+ /**
+ * Get credentials current cached, or fetch new credentials if none are
+ * available.
+ *
+ * These credentials may have expired already.
+ */
+ suspend fun getCredentialsCached(): PowerSyncCredentials? {
+ cachedCredentials?.let { return it }
+ return prefetchCredentials()
+ }
+
+ /**
+ * Immediately invalidate credentials.
+ *
+ * This may be called when the current credentials have expired.
+ */
+ fun invalidateCredentials() {
+ cachedCredentials = null
+ }
+
+ /**
+ * Fetch a new set of credentials and cache it.
+ *
+ * Until this call succeeds, [getCredentialsCached] will still return the
+ * old credentials.
+ *
+ * This may be called before the current credentials have expired.
+ */
+ @OptIn(DelicateCoroutinesApi::class)
+ suspend fun prefetchCredentials(): PowerSyncCredentials? {
+ fetchRequest = fetchRequest ?: GlobalScope.async {
+ fetchCredentials().also { value ->
+ cachedCredentials = value
+ }
+ }
+
+ return fetchRequest?.await()
+ }
+
+ /**
+ * Get credentials for PowerSync.
+ *
+ * This should always fetch a fresh set of credentials - don't use cached
+ * values.
+ *
+ * Return null if the user is not signed in. Throw an error if credentials
+ * cannot be fetched due to a network error or other temporary error.
+ *
+ * This token is kept for the duration of a sync connection.
+ */
+ abstract suspend fun fetchCredentials(): PowerSyncCredentials?
+
+ /**
+ * Upload local changes to the app backend.
+ *
+ * Use [PowerSyncDatabase.getCrudBatch] to get a batch of changes to upload.
+ *
+ * Any thrown errors will result in a retry after the configured wait period (default: 5 seconds).
+ */
+ abstract suspend fun uploadData(database: PowerSyncDatabase)
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncCredentials.kt b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncCredentials.kt
new file mode 100644
index 00000000..b8557187
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncCredentials.kt
@@ -0,0 +1,42 @@
+package com.powersync.connectors
+
+import kotlinx.datetime.Instant
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerialName
+
+/**
+ * Temporary credentials to connect to the PowerSync service.
+ */
+@Serializable
+data class PowerSyncCredentials(
+ /**
+ * PowerSync endpoint, e.g. "https://myinstance.powersync.co".
+ */
+ val endpoint: String,
+ /**
+ * Temporary token to authenticate against the service.
+ */
+ val token: String,
+ /**
+ * User ID.
+ */
+ @SerialName("user_id") val userId: String?,
+ /**
+ * When the token expires. Only use for debugging purposes.
+ */
+ val expiresAt: Instant?
+) {
+ override fun toString(): String {
+ return "PowerSyncCredentials"
+ }
+
+ fun endpointUri(path: String): String {
+ return "$endpoint/$path"
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt
new file mode 100644
index 00000000..646b34f7
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt
@@ -0,0 +1,228 @@
+package com.powersync.db
+
+import app.cash.sqldelight.SuspendingTransactionWithReturn
+import app.cash.sqldelight.async.coroutines.awaitAsList
+import app.cash.sqldelight.async.coroutines.awaitAsOne
+import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
+import app.cash.sqldelight.db.SqlCursor
+import com.powersync.DatabaseDriverFactory
+import com.powersync.PowerSyncDatabase
+import com.powersync.PsSqlDriver
+import com.powersync.bucket.BucketStorage
+import com.powersync.connectors.PowerSyncBackendConnector
+import com.powersync.db.crud.CrudBatch
+import com.powersync.db.crud.CrudEntry
+import com.powersync.db.crud.CrudRow
+import com.powersync.db.crud.CrudTransaction
+import com.powersync.db.internal.PsInternalDatabase
+import com.powersync.db.internal.PsInternalTable
+import com.powersync.db.schema.Schema
+import com.powersync.sync.SyncStatus
+import com.powersync.sync.SyncStream
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+/**
+ * A PowerSync managed database.
+ *
+ * Use one instance per database file.
+ *
+ * Use [PowerSyncDatabase.connect] to connect to the PowerSync service, to keep the local database in sync with the remote database.
+ *
+ * All changes to local tables are automatically recorded, whether connected or not. Once connected, the changes are uploaded.
+ */
+internal class PowerSyncDatabaseImpl(
+ val schema: Schema,
+ val scope: CoroutineScope,
+ val factory: DatabaseDriverFactory,
+ private val dbFilename: String,
+ driver: PsSqlDriver = factory.createDriver(scope, dbFilename),
+) : PowerSyncDatabase {
+ private val internalDb = PsInternalDatabase(driver, scope)
+ private val bucketStorage: BucketStorage = BucketStorage(internalDb)
+
+ /**
+ * The current sync status.
+ */
+ override val currentStatus: SyncStatus = SyncStatus()
+
+ override var syncStream: SyncStream? = null
+
+ init {
+ runBlocking {
+ val sqliteVersion = internalDb.queries.sqliteVersion().awaitAsOne()
+ println("SQLiteVersion: $sqliteVersion")
+ println("PowerSyncVersion: ${getPowerSyncVersion()}")
+ applySchema();
+ }
+ }
+
+ private suspend fun applySchema() {
+ val json = Json { encodeDefaults = true }
+ val schemaJson = json.encodeToString(schema)
+
+ this.writeTransaction {
+ internalDb.queries.replaceSchema(schemaJson).awaitAsOne()
+ }
+ }
+
+ @OptIn(FlowPreview::class)
+ override suspend fun connect(connector: PowerSyncBackendConnector) {
+ this.syncStream =
+ SyncStream(
+ bucketStorage = bucketStorage,
+ connector = connector,
+ uploadCrud = suspend { connector.uploadData(this) }
+ )
+
+ scope.launch {
+ syncStream!!.streamingSync()
+ }
+
+ scope.launch {
+ internalDb.updatesOnTable(PsInternalTable.CRUD.toString()).debounce(100).collect {
+ syncStream!!.triggerCrudUpload()
+ }
+ }
+ }
+
+ override suspend fun getCrudBatch(limit: Int): CrudBatch? {
+ if (!bucketStorage.hasCrud()) {
+ return null
+ }
+
+ val entries = internalDb.queries.getCrudEntries((limit + 1).toLong()).awaitAsList().map {
+ CrudEntry.fromRow(
+ CrudRow(
+ id = it.id.toString(),
+ data = it.data_!!,
+ txId = it.tx_id?.toInt()
+ )
+ )
+ }
+
+ if (entries.isEmpty()) {
+ return null
+ }
+
+ val hasMore = entries.size > limit;
+ if (hasMore) {
+ entries.dropLast(entries.size - limit)
+ }
+
+ return CrudBatch(entries, hasMore, complete = { writeCheckpoint ->
+ handleWriteCheckpoint(entries.last().clientId, writeCheckpoint)
+ })
+ }
+
+ override suspend fun getNextCrudTransaction(): CrudTransaction? {
+ return this.readTransaction {
+ val firstEntry = internalDb.queries.getCrudFirstEntry().awaitAsOneOrNull()
+ ?: return@readTransaction null
+
+ val first = CrudEntry.fromRow(
+ CrudRow(
+ id = firstEntry.id.toString(),
+ data = firstEntry.data_!!,
+ txId = firstEntry.tx_id?.toInt()
+ )
+ )
+
+ val txId = first.transactionId
+ val entries: List
+ if (txId == null) {
+ entries = listOf(first)
+ } else {
+ entries = internalDb.queries.getCrudEntryByTxId(txId.toLong()).awaitAsList().map {
+ CrudEntry.fromRow(
+ CrudRow(
+ id = it.id.toString(),
+ data = it.data_!!,
+ txId = it.tx_id?.toInt()
+ )
+ )
+ }
+ }
+
+ return@readTransaction CrudTransaction(
+ crud = entries, transactionId = txId,
+ complete = { writeCheckpoint ->
+ handleWriteCheckpoint(entries.last().clientId, writeCheckpoint)
+ }
+ )
+ }
+ }
+
+ override suspend fun getPowerSyncVersion(): String {
+ return internalDb.queries.powerSyncVersion().awaitAsOne()
+ }
+
+ override suspend fun get(
+ sql: String,
+ parameters: List?,
+ mapper: (SqlCursor) -> RowType
+ ): RowType {
+ return internalDb.get(sql, parameters, mapper)
+ }
+
+ override suspend fun getAll(
+ sql: String,
+ parameters: List?,
+ mapper: (SqlCursor) -> RowType
+ ): List {
+ return internalDb.getAll(sql, parameters, mapper)
+ }
+
+ override suspend fun getOptional(
+ sql: String,
+ parameters: List?,
+ mapper: (SqlCursor) -> RowType
+ ): RowType? {
+ return internalDb.getOptional(sql, parameters, mapper)
+ }
+
+ override fun watch(
+ sql: String,
+ parameters: List?,
+ mapper: (SqlCursor) -> RowType
+ ): Flow> {
+ return internalDb.watch(sql, parameters, mapper)
+ }
+
+
+ override suspend fun readTransaction(body: suspend SuspendingTransactionWithReturn.() -> R): R {
+ return internalDb.readTransaction(body)
+ }
+
+ override suspend fun writeTransaction(body: suspend SuspendingTransactionWithReturn.() -> R): R {
+ return internalDb.writeTransaction(body)
+ }
+
+ override suspend fun execute(sql: String, parameters: List?): Long {
+ return internalDb.execute(sql, parameters)
+ }
+
+ private suspend fun handleWriteCheckpoint(lastTransactionId: Int, writeCheckpoint: String?) {
+ writeTransaction {
+ internalDb.queries.deleteEntriesWithIdLessThan(lastTransactionId.toLong())
+
+ if (writeCheckpoint != null && bucketStorage.hasCrud()) {
+ execute(
+ "UPDATE ps_buckets SET target_op = ? WHERE name='\$local'",
+ listOf(writeCheckpoint),
+ )
+ } else {
+ execute(
+ "UPDATE ps_buckets SET target_op = ? WHERE name='\$local'",
+ listOf(bucketStorage.getMaxOpId()),
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/ReadQueries.kt b/core/src/commonMain/kotlin/com/powersync/db/ReadQueries.kt
new file mode 100644
index 00000000..ac615b59
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/ReadQueries.kt
@@ -0,0 +1,50 @@
+package com.powersync.db
+
+import app.cash.sqldelight.SuspendingTransactionWithReturn
+import app.cash.sqldelight.db.SqlCursor
+import kotlinx.coroutines.flow.Flow
+
+interface ReadQueries {
+
+ /**
+ * Execute a read-only (SELECT) query and return a single result.
+ */
+ suspend fun get(
+ sql: String,
+ parameters: List? = listOf(),
+ mapper: (SqlCursor) -> RowType
+ ): RowType
+
+ /**
+ * Execute a read-only (SELECT) query and return the results.
+ */
+ suspend fun getAll(
+ sql: String,
+ parameters: List? = listOf(),
+ mapper: (SqlCursor) -> RowType
+ ): List
+
+ /**
+ * Execute a read-only (SELECT) query and return a single optional result.
+ */
+ suspend fun getOptional(
+ sql: String,
+ parameters: List? = listOf(),
+ mapper: (SqlCursor) -> RowType
+ ): RowType?
+
+ /**
+ * Execute a read-only (SELECT) query every time the source tables are modified and return the results as a List in [Flow].
+ */
+ fun watch(
+ sql: String,
+ parameters: List? = listOf(),
+ mapper: (SqlCursor) -> RowType
+ ): Flow>
+
+
+ suspend fun readTransaction(body: suspend SuspendingTransactionWithReturn.() -> R): R
+
+}
+
+
diff --git a/core/src/commonMain/kotlin/com/powersync/db/WriteQueries.kt b/core/src/commonMain/kotlin/com/powersync/db/WriteQueries.kt
new file mode 100644
index 00000000..544c2409
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/WriteQueries.kt
@@ -0,0 +1,14 @@
+package com.powersync.db
+
+import app.cash.sqldelight.SuspendingTransactionWithReturn
+
+interface WriteQueries {
+
+ /**
+ * Execute a write query (INSERT, UPDATE, DELETE) and return the number of rows updated for an INSERT/DELETE/UPDATE.
+ */
+ suspend fun execute(sql: String, parameters: List? = listOf()): Long
+
+ suspend fun writeTransaction(body: suspend SuspendingTransactionWithReturn.() -> R): R
+
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudBatch.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudBatch.kt
new file mode 100644
index 00000000..b858dacb
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudBatch.kt
@@ -0,0 +1,23 @@
+package com.powersync.db.crud
+
+/**
+ * A batch of client-side changes.
+ */
+data class CrudBatch(
+ /**
+ * List of client-side changes.
+ */
+ val crud: List,
+
+ /**
+ * true if there are more changes in the local queue
+ */
+ val hasMore: Boolean,
+
+ /**
+ * Call to remove the changes from the local queue, once successfully uploaded.
+ *
+ * [writeCheckpoint] is optional.
+ */
+ val complete: suspend (writeCheckpoint: String?) -> Unit
+)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt
new file mode 100644
index 00000000..829f7a49
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt
@@ -0,0 +1,76 @@
+package com.powersync.db.crud
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+/**
+ * A single client-side change.
+ */
+data class CrudEntry(
+
+ /**
+ * ID of the changed row.
+ */
+ val id: String,
+
+ /**
+ * Auto-incrementing client-side id.
+ *
+ * Reset whenever the database is re-created.
+ */
+ val clientId: Int,
+
+ /**
+ * Type of change.
+ */
+ val op: UpdateType,
+
+ /**
+ * Table that contained the change.
+ */
+ val table: String,
+
+ /**
+ * Auto-incrementing transaction id. This is the same for all operations
+ * within the same transaction.
+ *
+ * Reset whenever the database is re-created.
+ *
+ * Currently, this is only present when [PowerSyncDatabase.writeTransaction] is used.
+ * This may change in the future.
+ */
+ val transactionId: Int?,
+
+ /**
+ * Data associated with the change.
+ *
+ * For PUT, this is contains all non-null columns of the row.
+ *
+ * For PATCH, this is contains the columns that changed.
+ *
+ * For DELETE, this is null.
+ */
+ val opData: Map?
+) {
+ companion object {
+ fun fromRow(row: CrudRow): CrudEntry {
+ val data = Json.parseToJsonElement(row.data).jsonObject
+ return CrudEntry(
+ id = data["id"]!!.jsonPrimitive.content,
+ clientId = row.id.toInt(),
+ op = UpdateType.fromJsonChecked(data["op"]!!.jsonPrimitive.content),
+ opData = data["data"]?.jsonObject?.mapValues { it.value.jsonPrimitive.content },
+ table = data["type"]!!.jsonPrimitive.content,
+ transactionId = row.txId,
+ )
+ }
+ }
+
+ override fun toString(): String {
+ return "CrudEntry<$transactionId/$clientId ${op.toJson()} $table/$id $opData>"
+ }
+
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudRow.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudRow.kt
new file mode 100644
index 00000000..36cf06da
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudRow.kt
@@ -0,0 +1,6 @@
+package com.powersync.db.crud
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CrudRow(val id: String, val data: String, val txId: Int?)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudTransaction.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudTransaction.kt
new file mode 100644
index 00000000..35e8a5ef
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudTransaction.kt
@@ -0,0 +1,27 @@
+package com.powersync.db.crud
+
+/**
+ * A transaction of client-side changes.
+ */
+data class CrudTransaction(
+ /**
+ * Unique transaction id.
+ *
+ * If null, this contains a list of changes recorded without an explicit transaction associated.
+ */
+ val transactionId: Int?,
+
+ /**
+ * List of client-side changes.
+ */
+ val crud: List,
+
+ /**
+ * Call to remove the changes from the local queue, once successfully uploaded.
+ *
+ * [writeCheckpoint] is optional.
+ */
+ val complete: suspend (writeCheckpoint: String?) -> Unit
+) {
+ override fun toString() = "CrudTransaction<$transactionId, $crud>"
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/UpdateType.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/UpdateType.kt
new file mode 100644
index 00000000..8f84b0e7
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/crud/UpdateType.kt
@@ -0,0 +1,37 @@
+package com.powersync.db.crud
+
+/**
+ * Type of local change.
+ */
+enum class UpdateType(val json: String) {
+ /**
+ * Insert or replace a row. All non-null columns are included in the data.
+ */
+ PUT("PUT"),
+
+ /**
+ * Update a row if it exists. All updated columns are included in the data.
+ */
+ PATCH("PATCH"),
+
+ /**
+ * Delete a row if it exists.
+ */
+ DELETE("DELETE");
+
+ fun toJson(): String {
+ return json
+ }
+
+ companion object {
+ fun fromJson(json: String): UpdateType? {
+ return entries.find { it.json == json }
+ }
+
+ fun fromJsonChecked(json: String): UpdateType {
+ val v = fromJson(json)
+ requireNotNull(v) { "Unexpected updateType: $json" }
+ return v
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/UploadQueueStats.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/UploadQueueStats.kt
new file mode 100644
index 00000000..f4046594
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/crud/UploadQueueStats.kt
@@ -0,0 +1,25 @@
+package com.powersync.db.crud
+
+/**
+ * Stats of the local upload queue.
+ */
+data class UploadQueueStats(
+ /**
+ * Number of records in the upload queue.
+ */
+ val count: Int,
+
+ /**
+ * Size of the upload queue in bytes.
+ */
+ val size: Int?
+) {
+
+ override fun toString(): String {
+ return if (size == null) {
+ "UploadQueueStats"
+ } else {
+ "UploadQueueStats"
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/PsInternalDatabase.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/PsInternalDatabase.kt
new file mode 100644
index 00000000..51dc6364
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PsInternalDatabase.kt
@@ -0,0 +1,250 @@
+package com.powersync.db.internal
+
+import app.cash.sqldelight.ExecutableQuery
+import app.cash.sqldelight.Query
+import app.cash.sqldelight.SuspendingTransactionWithReturn
+import app.cash.sqldelight.async.coroutines.awaitAsList
+import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
+import app.cash.sqldelight.coroutines.asFlow
+import app.cash.sqldelight.coroutines.mapToList
+import app.cash.sqldelight.db.QueryResult
+import app.cash.sqldelight.db.SqlCursor
+import app.cash.sqldelight.db.SqlPreparedStatement
+import com.powersync.PsSqlDriver
+import com.powersync.db.PsDatabase
+import com.powersync.db.ReadQueries
+import com.powersync.db.WriteQueries
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.launch
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+
+@OptIn(FlowPreview::class)
+class PsInternalDatabase(val driver: PsSqlDriver, private val scope: CoroutineScope) :
+ ReadQueries,
+ WriteQueries {
+
+ private val transactor: PsDatabase = PsDatabase(driver)
+ val queries = transactor.powersyncQueries
+
+ companion object {
+ const val POWERSYNC_TABLE_MATCH = "(^ps_data__|^ps_data_local__)"
+ const val DEFAULT_WATCH_THROTTLE_MS = 30L
+ }
+
+ init {
+ scope.launch {
+ tableUpdates().debounce(DEFAULT_WATCH_THROTTLE_MS).collect { tables ->
+ val dataTables = tables.map { toFriendlyTableName(it) }.filter { it.isNotBlank() }
+ driver.notifyListeners(queryKeys = dataTables.toTypedArray())
+ }
+ }
+ }
+
+ override suspend fun execute(
+ sql: String,
+ parameters: List?
+ ): Long {
+ val numParams = parameters?.size ?: 0
+
+ return createQuery(
+ sql,
+ parameters = numParams,
+ binders = getBindersFromParams(parameters)
+ ).awaitAsOneOrNull() ?: 0
+ }
+
+ override suspend fun get(
+ sql: String,
+ parameters: List?,
+ mapper: (SqlCursor) -> RowType
+ ): RowType {
+ return this.createQuery(
+ query = sql,
+ parameters = parameters?.size ?: 0,
+ binders = getBindersFromParams(parameters),
+ mapper = mapper
+ ).awaitAsOneOrNull()!!
+ }
+
+ override suspend fun getAll(
+ sql: String,
+ parameters: List?,
+ mapper: (SqlCursor) -> RowType
+ ): List {
+ return this.createQuery(
+ query = sql,
+ parameters = parameters?.size ?: 0,
+ binders = getBindersFromParams(parameters),
+ mapper = mapper
+ ).awaitAsList()
+ }
+
+ override suspend fun getOptional(
+ sql: String,
+ parameters: List?,
+ mapper: (SqlCursor) -> RowType
+ ): RowType? {
+ return this.createQuery(
+ query = sql,
+ parameters = parameters?.size ?: 0,
+ binders = getBindersFromParams(parameters),
+ mapper = mapper
+ ).awaitAsOneOrNull()
+ }
+
+ override fun watch(
+ sql: String,
+ parameters: List?,
+ mapper: (SqlCursor) -> RowType
+ ): Flow> {
+
+ val tables = getSourceTables(sql, parameters).map { toFriendlyTableName(it) }
+ .filter { it.isNotBlank() }.toSet()
+ return watchQuery(
+ query = sql,
+ parameters = parameters?.size ?: 0,
+ binders = getBindersFromParams(parameters),
+ mapper = mapper,
+ tables = tables
+ ).asFlow().mapToList(scope.coroutineContext)
+ }
+
+
+ private fun createQuery(
+ query: String,
+ parameters: Int = 0,
+ binders: (SqlPreparedStatement.() -> Unit)? = null,
+ ): ExecutableQuery {
+ return createQuery(query, { cursor -> cursor.getLong(0)!! }, parameters, binders)
+ }
+
+ private fun createQuery(
+ query: String,
+ mapper: (SqlCursor) -> T,
+ parameters: Int = 0,
+ binders: (SqlPreparedStatement.() -> Unit)? = null,
+ ): ExecutableQuery {
+ return object : ExecutableQuery(mapper) {
+ override fun execute(mapper: (SqlCursor) -> QueryResult): QueryResult {
+ return driver.executeQuery(null, query, mapper, parameters, binders)
+ }
+ }
+ }
+
+ private fun watchQuery(
+ query: String,
+ mapper: (SqlCursor) -> T,
+ parameters: Int = 0,
+ binders: (SqlPreparedStatement.() -> Unit)? = null,
+ tables: Set = setOf()
+ ): Query {
+
+ return object : Query(mapper) {
+ override fun execute(mapper: (SqlCursor) -> QueryResult): QueryResult {
+ return driver.executeQuery(null, query, mapper, parameters, binders);
+ }
+
+ override fun addListener(listener: Listener) {
+ driver.addListener(queryKeys = tables.toTypedArray(), listener = listener)
+ }
+
+ override fun removeListener(listener: Listener) {
+ driver.removeListener(queryKeys = tables.toTypedArray(), listener = listener)
+ }
+ }
+ }
+
+ override suspend fun readTransaction(body: suspend SuspendingTransactionWithReturn.() -> R): R {
+ return transactor.transactionWithResult(noEnclosing = true, body)
+ }
+
+ override suspend fun writeTransaction(body: suspend SuspendingTransactionWithReturn.() -> R): R {
+ return transactor.transactionWithResult(noEnclosing = true, body)
+ }
+
+ // Register callback for table updates
+ private fun tableUpdates(): Flow> {
+ return driver.tableUpdates()
+ }
+
+ // Register callback for table updates on a specific table
+ fun updatesOnTable(tableName: String): Flow {
+ return driver.updatesOnTable(tableName)
+ }
+
+ private fun toFriendlyTableName(tableName: String): String {
+ val regex = POWERSYNC_TABLE_MATCH.toRegex()
+ if (regex.containsMatchIn(tableName)) {
+ return tableName.replace(regex, "")
+ }
+ return tableName
+ }
+
+ private fun getSourceTables(
+ sql: String,
+ parameters: List?,
+ ): Set {
+ val rows = createQuery(
+ query = "EXPLAIN $sql",
+ parameters = parameters?.size ?: 0,
+ binders = getBindersFromParams(parameters),
+ mapper = {
+ ExplainQueryResult(
+ addr = it.getString(0)!!,
+ opcode = it.getString(1)!!,
+ p1 = it.getLong(2)!!,
+ p2 = it.getLong(3)!!,
+ p3 = it.getLong(4)!!
+ )
+ }
+ ).executeAsList()
+
+ val rootPages = mutableListOf()
+ for (row in rows) {
+ if ((row.opcode == "OpenRead" || row.opcode == "OpenWrite") && row.p3 == 0L && row.p2 != 0L) {
+ rootPages.add(row.p2)
+ }
+ }
+ val params = listOf(Json.encodeToString(rootPages))
+ val tableRows = createQuery(
+ "SELECT tbl_name FROM sqlite_master WHERE rootpage IN (SELECT json_each.value FROM json_each(?))",
+ parameters = params.size,
+ binders = {
+ bindString(0, params[0])
+ }, mapper = { it.getString(0)!! }
+ ).executeAsList()
+
+ return tableRows.toSet()
+ }
+
+ internal data class ExplainQueryResult(
+ val addr: String,
+ val opcode: String,
+ val p1: Long,
+ val p2: Long,
+ val p3: Long,
+ )
+}
+
+fun getBindersFromParams(parameters: List?): (SqlPreparedStatement.() -> Unit)? {
+ if (parameters.isNullOrEmpty()) {
+ return null
+ }
+ return {
+ parameters.forEachIndexed { index, parameter ->
+ when (parameter) {
+ is Boolean -> bindBoolean(index, parameter)
+ is String -> bindString(index, parameter)
+ is Long -> bindLong(index, parameter)
+ is Double -> bindDouble(index, parameter)
+ is ByteArray -> bindBytes(index, parameter)
+ else -> throw IllegalArgumentException("Unsupported parameter type: ${parameter::class}, at index $index")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/PsInternalSchema.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/PsInternalSchema.kt
new file mode 100644
index 00000000..37903d9c
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PsInternalSchema.kt
@@ -0,0 +1,22 @@
+package com.powersync.db.internal
+
+import app.cash.sqldelight.db.AfterVersion
+import app.cash.sqldelight.db.QueryResult
+import app.cash.sqldelight.db.SqlDriver
+import app.cash.sqldelight.db.SqlSchema
+
+public object PsInternalSchema : SqlSchema> {
+ override val version: Long
+ get() = 1
+
+ override fun create(driver: SqlDriver): QueryResult.AsyncValue = QueryResult.AsyncValue {
+ }
+
+ override fun migrate(
+ driver: SqlDriver,
+ oldVersion: Long,
+ newVersion: Long,
+ vararg callbacks: AfterVersion,
+ ): QueryResult.AsyncValue = QueryResult.AsyncValue {
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/PsInternalTable.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/PsInternalTable.kt
new file mode 100644
index 00000000..66984f70
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PsInternalTable.kt
@@ -0,0 +1,13 @@
+package com.powersync.db.internal
+
+enum class PsInternalTable(private val tableName: String) {
+ DATA("ps_data"),
+ CRUD("ps_crud"),
+ BUCKETS("ps_buckets"),
+ OPLOG("ps_oplog");
+
+
+ override fun toString(): String {
+ return tableName
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/Column.kt b/core/src/commonMain/kotlin/com/powersync/db/schema/Column.kt
new file mode 100644
index 00000000..b75deb44
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/schema/Column.kt
@@ -0,0 +1,31 @@
+package com.powersync.db.schema
+
+import kotlinx.serialization.Serializable
+
+/** A single column in a table schema. */
+@Serializable
+data class Column(
+ /** Name of the column. */
+ val name: String,
+
+ /** Type of the column.
+ *
+ * If the underlying data does not match this type,
+ * it is cast automatically.
+ *
+ * For details on the cast, see:
+ * https://www.sqlite.org/lang_expr.html#castexpr
+ */
+ val type: ColumnType
+) {
+ companion object {
+ /** Create a TEXT column. */
+ fun text(name: String) = Column(name, ColumnType.TEXT)
+
+ /** Create an INTEGER column. */
+ fun integer(name: String) = Column(name, ColumnType.INTEGER)
+
+ /** Create a REAL column. */
+ fun real(name: String) = Column(name, ColumnType.REAL)
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/ColumnType.kt b/core/src/commonMain/kotlin/com/powersync/db/schema/ColumnType.kt
new file mode 100644
index 00000000..7b31ed12
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/schema/ColumnType.kt
@@ -0,0 +1,7 @@
+package com.powersync.db.schema
+
+enum class ColumnType {
+ INTEGER,
+ TEXT,
+ REAL,
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/Index.kt b/core/src/commonMain/kotlin/com/powersync/db/schema/Index.kt
new file mode 100644
index 00000000..272630bb
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/schema/Index.kt
@@ -0,0 +1,43 @@
+package com.powersync.db.schema
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Index(
+ /**
+ * Descriptive name of the index.
+ */
+ val name: String,
+
+ /**
+ * List of columns used for the index.
+ */
+ val columns: List
+) {
+
+ /**
+ * Construct a new index with the specified column names.
+ */
+ companion object {
+ fun ascending(name: String, columns: List): Index {
+ return Index(name, columns.map { IndexedColumn.ascending(it) })
+ }
+ }
+
+ /**
+ * Internal use only.
+ * Specifies the full name of this index on a table.
+ */
+ fun fullName(table: Table): String {
+ return "${table.internalName}__$name"
+ }
+
+ /**
+ * Internal use only.
+ * Returns a SQL statement that creates this index.
+ */
+ fun toSqlDefinition(table: Table): String {
+ val fields = columns.joinToString(", ") { it.toSql(table) }
+ return """CREATE INDEX "${fullName(table)}" ON "${table.internalName}"($fields)"""
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/IndexedColumn.kt b/core/src/commonMain/kotlin/com/powersync/db/schema/IndexedColumn.kt
new file mode 100644
index 00000000..2c604f9f
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/schema/IndexedColumn.kt
@@ -0,0 +1,35 @@
+package com.powersync.db.schema
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Describes an indexed column.
+ */
+@Serializable
+data class IndexedColumn (
+ /**
+ * Name of the column to index.
+ */
+ val column: String,
+
+ /**
+ * Whether this column is stored in ascending order in the index.
+ */
+ private val ascending: Boolean = true
+) {
+ companion object {
+ fun ascending(column: String) = IndexedColumn(column, true)
+ fun descending(column: String) = IndexedColumn(column, false)
+ }
+
+ fun toSql(table: Table): String {
+ val fullColumn = table[column] // errors if not found
+ return fullColumn.let {
+ if (ascending) mapColumn(it) else "${mapColumn(it)} DESC"
+ }
+ }
+}
+
+fun mapColumn(column: Column): String {
+ return "CAST(json_extract(data, ${column.name}) as ${column.type})"
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/Schema.kt b/core/src/commonMain/kotlin/com/powersync/db/schema/Schema.kt
new file mode 100644
index 00000000..c79720a6
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/schema/Schema.kt
@@ -0,0 +1,6 @@
+package com.powersync.db.schema
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Schema(val tables: List)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt b/core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt
new file mode 100644
index 00000000..c9368118
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt
@@ -0,0 +1,161 @@
+package com.powersync.db.schema
+
+import com.powersync.invalidSqliteCharacters
+import kotlinx.serialization.Serializable
+
+/**
+ * A single table in the schema.
+ */
+@Serializable
+
+data class Table constructor(
+ /**
+ * The synced table name, matching sync rules.
+ */
+ var name: String,
+ /**
+ * List of columns.
+ */
+ var columns: List,
+ /**
+ * List of indexes.
+ */
+ var indexes: List = listOf(),
+ /**
+ * Whether the table only exists only.
+ */
+ private val localOnly: Boolean = false,
+ /**
+ * Whether this is an insert-only table.
+ */
+ private val insertOnly: Boolean = false,
+ /**
+ * Override the name for the view
+ */
+ private val _viewNameOverride: String? = null
+) {
+ companion object {
+ /**
+ * Create a table that only exists locally.
+ *
+ * This table does not record changes, and is not synchronized from the service.
+ */
+ fun localOnly(
+ name: String,
+ columns: List,
+ indexes: List = listOf(),
+ viewName: String? = null
+ ): Table {
+ return Table(
+ name,
+ columns,
+ indexes,
+ localOnly = true,
+ insertOnly = false,
+ _viewNameOverride = viewName
+ )
+ }
+
+ /**
+ * Create a table that only supports inserts.
+ *
+ * This table records INSERT statements, but does not persist data locally.
+ *
+ * SELECT queries on the table will always return 0 rows.
+ */
+ fun insertOnly(name: String, columns: List, viewName: String? = null): Table {
+ return Table(
+ name,
+ columns,
+ indexes = listOf(),
+ localOnly = false,
+ insertOnly = true,
+ _viewNameOverride = viewName
+ )
+ }
+ }
+
+ /**
+ * Internal use only.
+ *
+ * Name of the table that stores the underlying data.
+ */
+ val internalName: String
+ get() = if (localOnly) "ps_data_local__$name" else "ps_data__$name"
+
+ operator fun get(columnName: String): Column {
+ return columns.first { it.name == columnName }
+ }
+
+ /**
+ * Whether this table name is valid.
+ */
+ val validName: Boolean
+ get() = !invalidSqliteCharacters.containsMatchIn(name) &&
+ (_viewNameOverride == null || !invalidSqliteCharacters.containsMatchIn(
+ _viewNameOverride
+ ))
+
+
+ /**
+ * Check that there are no issues in the table definition.
+ */
+ fun validate() {
+ if (invalidSqliteCharacters.containsMatchIn(name)) {
+ throw AssertionError("Invalid characters in table name: $name")
+ } else if (_viewNameOverride != null && invalidSqliteCharacters.containsMatchIn(
+ _viewNameOverride
+ )
+ ) {
+ throw AssertionError("Invalid characters in view name: $_viewNameOverride")
+ }
+
+ val columnNames = mutableSetOf("id")
+ for (column in columns) {
+ when {
+ column.name == "id" -> {
+ throw AssertionError("$name: id column is automatically added, custom id columns are not supported")
+ }
+
+ columnNames.contains(column.name) -> {
+ throw AssertionError("Duplicate column $name.${column.name}")
+ }
+
+ invalidSqliteCharacters.containsMatchIn(column.name) -> {
+ throw AssertionError("Invalid characters in column name: $name.${column.name}")
+ }
+
+ else -> columnNames.add(column.name)
+ }
+ }
+
+ val indexNames = mutableSetOf()
+ for (index in indexes) {
+ when {
+ indexNames.contains(index.name) -> {
+ throw AssertionError("Duplicate index $name.${index.name}")
+ }
+
+ invalidSqliteCharacters.containsMatchIn(index.name) -> {
+ throw AssertionError("Invalid characters in index name: $name.${index.name}")
+ }
+
+ else -> {
+ for (column in index.columns) {
+ if (!columnNames.contains(column.column)) {
+ throw AssertionError("Column $name.${column.column} not found for index ${index.name}")
+ }
+ }
+ indexNames.add(index.name)
+ }
+ }
+ }
+ }
+
+ /**
+ * Name for the view, used for queries.
+ * Defaults to the synced table name.
+ */
+ val viewName: String
+ get() = _viewNameOverride ?: name
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/validation.kt b/core/src/commonMain/kotlin/com/powersync/db/schema/validation.kt
new file mode 100644
index 00000000..abb41055
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/db/schema/validation.kt
@@ -0,0 +1,3 @@
+package com.powersync
+
+val invalidSqliteCharacters = Regex("""["'%,.#\\s\\[\\]]""")
diff --git a/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncCheckpointDiff.kt b/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncCheckpointDiff.kt
new file mode 100644
index 00000000..11eb6865
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncCheckpointDiff.kt
@@ -0,0 +1,13 @@
+package com.powersync.sync
+
+import com.powersync.bucket.BucketChecksum
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class StreamingSyncCheckpointDiff(
+ @SerialName("last_op_id") val lastOpId: String,
+ @SerialName("updated_buckets") val updatedBuckets: List,
+ @SerialName("removed_buckets") val removedBuckets: List,
+ @SerialName("write_checkpoint") val writeCheckpoint: String
+)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt b/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt
new file mode 100644
index 00000000..60c7f2df
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt
@@ -0,0 +1,13 @@
+package com.powersync.sync
+
+import com.powersync.bucket.BucketRequest
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class StreamingSyncRequest(
+ val buckets: List,
+ @SerialName("include_checksum") val includeChecksum: Boolean = true,
+) {
+ @SerialName("raw_data") private val rawData: Boolean = true
+}
diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt
new file mode 100644
index 00000000..b2674e76
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt
@@ -0,0 +1,6 @@
+package com.powersync.sync
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SyncDataBatch(val buckets: List)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBucket.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBucket.kt
new file mode 100644
index 00000000..e3d97082
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBucket.kt
@@ -0,0 +1,14 @@
+package com.powersync.sync
+
+import com.powersync.bucket.OplogEntry
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SyncDataBucket (
+ val bucket: String,
+ val data: List,
+ @SerialName("has_more") val hasMore: Boolean = false,
+ val after: String?,
+ @SerialName("next_after")val nextAfter: String?
+)
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncLocalDatabaseResult.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncLocalDatabaseResult.kt
new file mode 100644
index 00000000..d0577cb1
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncLocalDatabaseResult.kt
@@ -0,0 +1,14 @@
+package com.powersync.sync
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SyncLocalDatabaseResult(
+ var ready: Boolean = true,
+ @SerialName("valid") val checkpointValid: Boolean = true,
+ @SerialName("failed_buckets") val checkpointFailures: List? = null
+) {
+ override fun toString() =
+ "SyncLocalDatabaseResult"
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt
new file mode 100644
index 00000000..a19f4e10
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt
@@ -0,0 +1,57 @@
+package com.powersync.sync
+
+import kotlinx.datetime.Instant
+
+data class SyncStatus(
+ /**
+ * true if currently connected.
+ *
+ * This means the PowerSync connection is ready to download, and [PowerSyncBackendConnector.uploadData] may be called for any local changes.
+ */
+ var connected: Boolean = false,
+ /**
+ * true if the PowerSync connection is busy connecting.
+ *
+ * During this stage, [PowerSyncBackendConnector.uploadData] may already be called, and [uploading] may be true.
+ */
+ var connecting: Boolean = false,
+ /**
+ * true if actively downloading changes.
+ *
+ * This is only true when [connected] is also true.
+ */
+ var downloading: Boolean = false,
+ /**
+ * true if uploading changes
+ */
+ var uploading: Boolean = false,
+ /**
+ * Time that a last sync has fully completed, if any.
+ *
+ * Currently this is reset to null after a restart.
+ */
+ var lastSyncedAt: Instant? = null,
+ /**
+ * Error during uploading.
+ *
+ * Cleared on the next successful upload.
+ */
+ var uploadError: Any? = null,
+ /**
+ * Error during downloading (including connecting).
+ *
+ * Cleared on the next successful data download.
+ */
+ var downloadError: Any? = null
+) {
+
+ /**
+ * Get the current [downloadError] or [uploadError].
+ */
+ val anyError: Any?
+ get() = downloadError ?: uploadError
+
+ override fun toString(): String {
+ return "SyncStatus(connected=$connected, connecting=$connecting, downloading=$downloading, uploading=$uploading, lastSyncedAt=$lastSyncedAt, error=$anyError)"
+ }
+}
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt
new file mode 100644
index 00000000..ac517a0e
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt
@@ -0,0 +1,414 @@
+package com.powersync.sync
+
+import com.powersync.bucket.BucketChecksum
+import com.powersync.bucket.BucketRequest
+import com.powersync.bucket.BucketStorage
+import com.powersync.bucket.Checkpoint
+import com.powersync.bucket.WriteCheckpointResponse
+import co.touchlab.stately.concurrency.AtomicBoolean
+import com.powersync.connectors.PowerSyncBackendConnector
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.timeout
+import io.ktor.client.request.get
+import io.ktor.client.request.headers
+import io.ktor.client.request.preparePost
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.contentType
+import io.ktor.utils.io.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.retryWhen
+import kotlinx.datetime.Instant
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.decodeFromJsonElement
+
+class SyncStream(
+ private val bucketStorage: BucketStorage,
+ private val connector: PowerSyncBackendConnector,
+ private val uploadCrud: suspend () -> Unit,
+ private val retryDelay: Long = 1000L
+) {
+ private var isUploadingCrud = AtomicBoolean(false)
+
+ private var lastStatus = SyncStatus()
+ private val httpClient: HttpClient = HttpClient {
+ install(HttpTimeout)
+ install(ContentNegotiation)
+ };
+ private val statusStreamController = MutableStateFlow(SyncStatus())
+
+ companion object {
+ private val _noError = Any()
+
+ fun isStreamingSyncData(obj: JsonObject): Boolean {
+ return obj.containsKey("data")
+ }
+
+ fun isStreamingKeepAlive(obj: JsonObject): Boolean {
+ return obj.containsKey("token_expires_in")
+ }
+
+ fun isStreamingSyncCheckpoint(obj: JsonObject): Boolean {
+ return obj.containsKey("checkpoint")
+ }
+
+ fun isStreamingSyncCheckpointComplete(obj: JsonObject): Boolean {
+ return obj.containsKey("checkpoint_complete")
+ }
+
+ fun isStreamingSyncCheckpointDiff(obj: JsonObject): Boolean {
+ return obj.containsKey("checkpoint_diff")
+ }
+ }
+
+ suspend fun streamingSync() {
+ var invalidCredentials = false
+ while (true) {
+ updateStatus(connecting = true)
+ try {
+ if (invalidCredentials) {
+ // This may error. In that case it will be retried again on the next
+ // iteration.
+ connector.invalidateCredentials()
+ invalidCredentials = false
+ }
+ streamingSyncIteration()
+ } catch (e: Exception) {
+ println("SyncStream::streamingSync Error: $e")
+ invalidCredentials = true
+ updateStatus(
+ connected = false,
+ connecting = true,
+ downloading = false,
+ downloadError = e
+ )
+ delay(retryDelay)
+ }
+ }
+ }
+
+ suspend fun triggerCrudUpload() {
+ if (isUploadingCrud.value) {
+ return
+ }
+ isUploadingCrud.value = true
+ uploadAllCrud()
+ isUploadingCrud.value = false
+ }
+
+ private suspend fun uploadAllCrud() {
+ while (true) {
+ try {
+ val done = uploadCrudBatch()
+ updateStatus(uploadError = _noError)
+ if (done) {
+ break
+ }
+ } catch (e: Exception) {
+ println("[SyncStream::uploadAllCrud] Error uploading crud: $e")
+ updateStatus(uploading = false, uploadError = e)
+ delay(retryDelay)
+ break
+ }
+ }
+ updateStatus(uploading = false)
+ }
+
+ private suspend fun uploadCrudBatch(): Boolean {
+ if (bucketStorage.hasCrud()) {
+ updateStatus(uploading = true)
+ uploadCrud()
+ return false
+ } else {
+ // This isolate is the only one triggering
+ bucketStorage.updateLocalTarget { getWriteCheckpoint() }
+ return true
+ }
+ }
+
+ private suspend fun getWriteCheckpoint(): String {
+ val credentials = connector.getCredentialsCached()
+ require(credentials != null) { "Not logged in" }
+ val uri = credentials.endpointUri("write-checkpoint2.json")
+
+ val response = httpClient.get(uri) {
+ contentType(ContentType.Application.Json)
+ headers {
+ append(HttpHeaders.Authorization, "Token ${credentials.token}")
+ append("User-Id", credentials.userId ?: "")
+ }
+ }
+ if (response.status.value == 401) {
+ connector.invalidateCredentials()
+ }
+ if (response.status.value != 200) {
+ throw Exception("Error getting write checkpoint: ${response.status}")
+ }
+
+ val body = Json.decodeFromString(response.body())
+ return body.data.writeCheckpoint
+ }
+
+ private suspend fun streamingSyncRequest(req: StreamingSyncRequest): Flow = flow {
+ val credentials = connector.getCredentialsCached()
+ require(credentials != null) { "Not logged in" }
+
+ val uri = credentials.endpointUri("sync/stream")
+
+ val bodyJson = Json.encodeToString(req)
+
+ val request = httpClient.preparePost(uri) {
+ contentType(ContentType.Application.Json)
+ headers {
+ append(HttpHeaders.Authorization, "Token ${credentials.token}")
+ append("User-Id", credentials.userId ?: "")
+ }
+ timeout { socketTimeoutMillis = Long.MAX_VALUE }
+ setBody(bodyJson)
+ }
+
+ request.execute { httpResponse ->
+ val channel: ByteReadChannel = httpResponse.body()
+
+ while (!channel.isClosedForRead) {
+ val line = channel.readUTF8Line()
+ if (line != null) {
+ emit(line)
+ }
+ }
+ }
+ }
+
+ private suspend fun streamingSyncIteration() {
+ val bucketEntries = bucketStorage.getBucketStates()
+ val initialBuckets = mutableMapOf()
+
+ val state = SyncStreamState(
+ targetCheckpoint = null,
+ validatedCheckpoint = null,
+ appliedCheckpoint = null,
+ bucketSet = initialBuckets.keys.toMutableSet(),
+ retry = false
+ )
+
+ bucketEntries.forEach { entry ->
+ initialBuckets[entry.bucket] = entry.opId
+ }
+
+ val req = StreamingSyncRequest(
+ buckets = initialBuckets.map { (bucket, after) -> BucketRequest(bucket, after) },
+ )
+
+ streamingSyncRequest(req).retryWhen { cause, attempt ->
+ println("SyncStream::streamingSyncIteration Error: $cause")
+ delay(retryDelay)
+ println("SyncStream::streamingSyncIteration Retrying attempt: $attempt")
+ true
+ }.collect { value ->
+ handleInstruction(value, state)
+ }
+ }
+
+ private suspend fun handleInstruction(
+ jsonString: String,
+ state: SyncStreamState
+ ): SyncStreamState {
+ println("[SyncStream::handleInstruction] Received Instruction: $jsonString")
+ val json = Json { ignoreUnknownKeys = true }
+ val obj = json.parseToJsonElement(jsonString).jsonObject
+
+ // TODO: Clean up
+ when (true) {
+ isStreamingSyncCheckpoint(obj) -> return handleStreamingSyncCheckpoint(obj, state)
+ isStreamingSyncCheckpointComplete(obj) -> return handleStreamingSyncCheckpointComplete(
+ state
+ )
+
+ isStreamingSyncCheckpointDiff(obj) -> return handleStreamingSyncCheckpointDiff(
+ obj,
+ state
+ )
+
+ isStreamingSyncData(obj) -> return handleStreamingSyncData(obj, state)
+ isStreamingKeepAlive(obj) -> return handleStreamingKeepalive(obj, state)
+ else -> {
+ println("Unhandled instruction")
+ return state
+ }
+ }
+ }
+
+ private suspend fun handleStreamingSyncCheckpoint(
+ jsonObj: JsonObject,
+ state: SyncStreamState
+ ): SyncStreamState {
+ val checkpoint =
+ Json.decodeFromJsonElement(jsonObj["checkpoint"] as JsonElement)
+
+ state.targetCheckpoint = checkpoint
+ val bucketsToDelete = state.bucketSet!!.toMutableList()
+ val newBuckets = mutableSetOf()
+
+ checkpoint.checksums.forEach { checksum ->
+ run {
+ newBuckets.add(checksum.bucket)
+ bucketsToDelete.remove(checksum.bucket)
+ }
+ }
+
+ if (bucketsToDelete.size > 0) {
+ println("Removing buckets [${bucketsToDelete.joinToString(separator = ", ")}]")
+ }
+
+ state.bucketSet = newBuckets
+ bucketStorage.removeBuckets(bucketsToDelete)
+ bucketStorage.setTargetCheckpoint(checkpoint)
+
+ return state
+ }
+
+ private suspend fun handleStreamingSyncCheckpointComplete(
+ state: SyncStreamState
+ ): SyncStreamState {
+ val result = bucketStorage.syncLocalDatabase(state.targetCheckpoint!!)
+ if (!result.checkpointValid) {
+ // This means checksums failed. Start again with a new checkpoint.
+ // TODO: better back-off
+ delay(50)
+ state.retry = true
+ // TODO handle retries
+ return state
+ } else if (!result.ready) {
+ // Checksums valid, but need more data for a consistent checkpoint.
+ // Continue waiting.
+ // landing here the whole time
+ } else {
+ state.appliedCheckpoint = state.targetCheckpoint!!.clone()
+ println("validated checkpoint ${state.appliedCheckpoint}")
+ }
+
+ state.validatedCheckpoint = state.targetCheckpoint
+
+ return state
+ }
+
+ private suspend fun handleStreamingSyncCheckpointDiff(
+ jsonObj: JsonObject,
+ state: SyncStreamState
+ ): SyncStreamState {
+ // TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint
+ if (state.targetCheckpoint == null) {
+ throw Exception("Checkpoint diff without previous checkpoint")
+ }
+ val checkpointDiff =
+ Json.decodeFromJsonElement(jsonObj["checkpoint_diff"]!!)
+
+ val newBuckets = mutableMapOf()
+
+ state.targetCheckpoint!!.checksums.forEach { checksum ->
+ newBuckets[checksum.bucket] = checksum
+ }
+ checkpointDiff.updatedBuckets.forEach { checksum ->
+ newBuckets[checksum.bucket] = checksum
+ }
+
+ checkpointDiff.removedBuckets.forEach { bucket -> newBuckets.remove(bucket) }
+
+ val newCheckpoint = Checkpoint(
+ lastOpId = checkpointDiff.lastOpId,
+ checksums = newBuckets.values.toList(),
+ writeCheckpoint = checkpointDiff.writeCheckpoint
+ )
+
+ state.targetCheckpoint = newCheckpoint
+
+ state.bucketSet = newBuckets.keys.toMutableSet()
+
+ val bucketsToDelete = checkpointDiff.removedBuckets
+ if (bucketsToDelete.isNotEmpty()) {
+ println("Remove buckets $bucketsToDelete")
+ }
+ bucketStorage.removeBuckets(bucketsToDelete)
+ bucketStorage.setTargetCheckpoint(state.targetCheckpoint!!)
+
+ return state
+ }
+
+ private suspend fun handleStreamingSyncData(
+ jsonObj: JsonObject,
+ state: SyncStreamState
+ ): SyncStreamState {
+
+ val json = Json { isLenient = true }
+
+ val syncBuckets =
+ listOf(json.decodeFromJsonElement(jsonObj["data"] as JsonElement))
+
+ bucketStorage.saveSyncData(SyncDataBatch(syncBuckets))
+
+ return state
+ }
+
+ suspend fun handleStreamingKeepalive(
+ jsonObj: JsonObject,
+ state: SyncStreamState
+ ): SyncStreamState {
+ val tokenExpiresIn = (jsonObj["token_expires_in"] as JsonPrimitive).content.toInt()
+
+ if (tokenExpiresIn <= 0) {
+ // Connection would be closed automatically right after this
+ println("Token expiring reconnect")
+ state.retry = true
+ return state
+ }
+ triggerCrudUpload()
+ return state
+ }
+
+ private fun updateStatus(
+ lastSyncedAt: Instant? = null,
+ connected: Boolean? = null,
+ connecting: Boolean? = null,
+ downloading: Boolean? = null,
+ uploading: Boolean? = null,
+ uploadError: Any? = null,
+ downloadError: Any? = null,
+ ) {
+ val c = connected ?: lastStatus.connected
+ val newStatus = SyncStatus(
+ connected = c,
+ connecting = !c && (connecting ?: lastStatus.connecting),
+ lastSyncedAt = lastSyncedAt ?: lastStatus.lastSyncedAt,
+ downloading = downloading ?: lastStatus.downloading,
+ uploading = uploading ?: lastStatus.uploading,
+ uploadError = if (uploadError == _noError) null else uploadError
+ ?: lastStatus.uploadError,
+ downloadError = if (downloadError == _noError) null else downloadError
+ ?: lastStatus.downloadError
+ )
+ lastStatus = newStatus
+ statusStreamController.value = newStatus
+ }
+
+}
+
+data class SyncStreamState(
+ var targetCheckpoint: Checkpoint?,
+ var validatedCheckpoint: Checkpoint?,
+ var appliedCheckpoint: Checkpoint?,
+
+ var bucketSet: MutableSet?,
+ var retry: Boolean,
+)
\ No newline at end of file
diff --git a/core/src/commonMain/sqldelight/com/powersync/Powersync.sq b/core/src/commonMain/sqldelight/com/powersync/Powersync.sq
new file mode 100644
index 00000000..34862214
--- /dev/null
+++ b/core/src/commonMain/sqldelight/com/powersync/Powersync.sq
@@ -0,0 +1,50 @@
+-- Core queries
+sqliteVersion:
+SELECT sqlite_version();
+
+powerSyncVersion:
+SELECT powersync_rs_version();
+
+replaceSchema:
+SELECT powersync_replace_schema(?);
+
+-- CRUD operations
+hasCrud:
+SELECT 1 FROM ps_crud LIMIT 1;
+
+getCrudEntries:
+SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT ?;
+
+getCrudFirstEntry:
+SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT 1;
+
+getCrudEntryByTxId:
+SELECT id, tx_id, data FROM ps_crud WHERE tx_id = ? ORDER BY id ASC;
+
+deleteEntriesWithIdLessThan:
+DELETE FROM ps_crud WHERE id <= ?;
+
+-- Internal tables used by PowerSync. Once (https://github.com/cashapp/sqldelight/pull/4006) is merged,
+-- we can define interal tables as part of the dialect.
+CREATE TABLE IF NOT EXISTS ps_crud (id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT, tx_id INTEGER);
+
+CREATE TABLE ps_buckets(
+ name TEXT PRIMARY KEY,
+ last_applied_op INTEGER NOT NULL DEFAULT 0,
+ last_op INTEGER NOT NULL DEFAULT 0,
+ target_op INTEGER NOT NULL DEFAULT 0,
+ add_checksum INTEGER NOT NULL DEFAULT 0,
+ pending_delete INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS ps_oplog(
+ bucket TEXT NOT NULL,
+ op_id INTEGER NOT NULL,
+ op INTEGER NOT NULL,
+ row_type TEXT,
+ row_id TEXT,
+ key TEXT,
+ data TEXT,
+ hash INTEGER NOT NULL,
+ superseded INTEGER NOT NULL
+);
\ No newline at end of file
diff --git a/core/src/commonTest/kotlin/com/powersync/FoobarTest.kt b/core/src/commonTest/kotlin/com/powersync/FoobarTest.kt
new file mode 100644
index 00000000..38605093
--- /dev/null
+++ b/core/src/commonTest/kotlin/com/powersync/FoobarTest.kt
@@ -0,0 +1,12 @@
+package com.powersync
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class FoobarTest {
+
+ @Test
+ fun `test true`() {
+ assertEquals(true, true)
+ }
+}
\ No newline at end of file
diff --git a/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt b/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt
new file mode 100644
index 00000000..c48f0b84
--- /dev/null
+++ b/core/src/iosMain/kotlin/com/powersync/DatabaseDriverFactory.ios.kt
@@ -0,0 +1,122 @@
+package com.powersync
+
+import app.cash.sqldelight.async.coroutines.synchronous
+import app.cash.sqldelight.driver.native.NativeSqliteDriver
+import app.cash.sqldelight.driver.native.wrapConnection
+import co.touchlab.sqliter.DatabaseConfiguration
+import co.touchlab.sqliter.DatabaseConnection
+import com.powersync.db.internal.PsInternalSchema
+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.ExperimentalForeignApi
+import kotlinx.cinterop.MemScope
+import kotlinx.cinterop.StableRef
+import kotlinx.cinterop.asStableRef
+import kotlinx.cinterop.staticCFunction
+import kotlinx.cinterop.toKString
+import kotlinx.coroutines.CoroutineScope
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+@OptIn(ExperimentalForeignApi::class)
+actual class DatabaseDriverFactory {
+ private var driver: PsSqlDriver? = null
+
+ init {
+ init_powersync_sqlite_extension()
+ }
+
+ @Suppress("unused")
+ private fun updateTableHook(opType: Int, databaseName: String, tableName: String, rowId: Long) {
+ driver?.updateTable(tableName)
+ }
+
+ private fun onTransactionCommit(success: Boolean) {
+ driver?.also { driver ->
+ if (success) {
+ driver.fireTableUpdates()
+ } else {
+ driver.clearTableUpdates()
+ }
+ }
+ }
+
+ actual fun createDriver(
+ scope: CoroutineScope,
+ dbFilename: String,
+ ): PsSqlDriver {
+ val schema = PsInternalSchema.synchronous()
+ this.driver = PsSqlDriver(scope = scope, driver = NativeSqliteDriver(
+ configuration = DatabaseConfiguration(
+ name = dbFilename,
+ version = schema.version.toInt(),
+ create = { connection -> wrapConnection(connection) { schema.create(it) } },
+ lifecycleConfig = DatabaseConfiguration.Lifecycle(
+ onCreateConnection = { connection ->
+ setupSqliteBinding(connection)
+ wrapConnection(connection) { driver ->
+ schema.create(driver)
+ }
+ },
+ onCloseConnection = { connection ->
+ deregisterSqliteBinding(connection)
+ }
+ )
+ )
+ )
+ )
+ return this.driver as PsSqlDriver
+ }
+
+ private fun setupSqliteBinding(connection: DatabaseConnection) {
+ val ptr = connection.getDbPointer().getPointer(MemScope())
+
+ // 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
+ )
+ },
+ StableRef.create(::updateTableHook).asCPointer()
+ )
+
+ // Register transaction hooks
+ sqlite3_commit_hook(
+ ptr,
+ staticCFunction { usrPtr ->
+ val callback = usrPtr!!.asStableRef<(Boolean) -> Unit>().get()
+ callback(true)
+ 0
+ },
+ StableRef.create(::onTransactionCommit).asCPointer()
+ )
+ sqlite3_rollback_hook(
+ ptr,
+ staticCFunction { usrPtr ->
+ val callback = usrPtr!!.asStableRef<(Boolean) -> Unit>().get()
+ callback(false)
+ 0
+ },
+ StableRef.create(::onTransactionCommit).asCPointer()
+ )
+ }
+
+ private fun deregisterSqliteBinding(connection: DatabaseConnection) {
+ val ptr = connection.getDbPointer().getPointer(MemScope())
+ sqlite3_update_hook(
+ ptr,
+ null,
+ null
+ )
+ }
+}
+
diff --git a/core/src/nativeInterop/cinterop/powersync-sqlite-core.def b/core/src/nativeInterop/cinterop/powersync-sqlite-core.def
new file mode 100644
index 00000000..0e5e6f3c
--- /dev/null
+++ b/core/src/nativeInterop/cinterop/powersync-sqlite-core.def
@@ -0,0 +1,15 @@
+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/hello-powersync/.gitignore b/demos/hello-powersync/.gitignore
new file mode 100644
index 00000000..33e4c75d
--- /dev/null
+++ b/demos/hello-powersync/.gitignore
@@ -0,0 +1,17 @@
+*.iml
+.gradle
+**/build/
+xcuserdata
+!src/**/build/
+local.properties
+.idea
+.DS_Store
+captures
+.externalNativeBuild
+.cxx
+*.xcodeproj/*
+!*.xcodeproj/project.pbxproj
+!*.xcodeproj/xcshareddata/
+!*.xcodeproj/project.xcworkspace/
+!*.xcworkspace/contents.xcworkspacedata
+**/xcshareddata/WorkspaceSettings.xcsettings
diff --git a/demos/hello-powersync/README.md b/demos/hello-powersync/README.md
new file mode 100644
index 00000000..b66b4403
--- /dev/null
+++ b/demos/hello-powersync/README.md
@@ -0,0 +1,105 @@
+# Hello-PowerSync Demo App
+
+This is a minimal demo app demonstrating use of the PowerSync Kotlin Mutiplatform SDK together
+with [Supabase](https://supabase.com/) in a basic Kotlin Multiplatform Compose App.
+
+The app lists customers and allows you to add or delete rows.
+Data is [synced to users globally](https://docs.powersync.com/usage/sync-rules/example-global-data). For more advanced sync rules and functionality, see the [PowerSync+Supabase Todo-List](../supabase-todolist/README.md) demo application.
+
+Supported KMP targets: Android and iOS.
+
+## Alpha release
+
+The Kotlin Multiplatform SDK is currently in an alpha release. If you find a bug or issue, please
+open a [GitHub issue](https://github.com/powersync-ja/powersync-kotlin/issues). Questions or
+feedback can be posted on our [community Discord](https://discord.gg/powersync) - we'd love to hear
+from you.
+
+## Set up Supabase project and auth user
+1. Create a new Supabase project, and run the below in the Supabase SQL editor. This creates a `customers` table and a publication called `powersync` for the `customers` table.
+
+```sql
+-- Create tables
+create table
+ public.customers (
+ id uuid not null default gen_random_uuid (),
+ name text not null,
+ email text not null,
+ constraint customers_pkey primary key (id)
+ ) tablespace pg_default;
+
+
+drop publication powersync;
+
+-- Create publication for powersync
+create publication powersync for table customers;
+
+```
+
+2. Create a user which this demo app will use for authentication. This is done under *Authentication* > *Users* > *Add user*. Enter an email address and password for the user.
+
+## Set up PowerSync instance
+1. In your [PowerSync dashboard](https://powersync.journeyapps.com/), create a new PowerSync instance, connecting to the database of the Supabase project. Find detailed instructions in the [Connect PowerSync to Your Supabase](https://docs.powersync.com/integration-guides/supabase-+-powersync#connect-powersync-to-your-supabase) section of the Supabase<>PowerSync integration guide.
+
+2. Then deploy the sync rules to the newly created instance.
+ - Open the `sync-rules.yaml` file.
+ - Replace the file's contents with the below:
+
+ ```yml
+ # sync-rules.yaml
+
+ bucket_definitions:
+ global:
+ data:
+ # Sync all rows
+ - SELECT * FROM public.customers
+ ```
+ - Deploy to the newly created instance.
+
+## Set up your development environment
+* To setup your environment, please consult [these instructions](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-setup.html).
+* JDK version 17 is required.
+* Android Gradle plugin version 8.2.2 is required.
+
+## Configure project in Android Studio
+
+1. Clone this repo: ```git clone https://github.com/powersync-ja/powersync-kotlin.git```
+2. Open the repo in Android Studio. This creates a `local.properties` file in root and should contain a `sdk.dir=/path/to/android/sdk` line.
+3. Sync the project with Gradle (this should happen automatically, or choose File > Sync project with Gradle Files).
+4. Open the `demos/hello-powersync` directory in Android Studio and sync this project with Gradle.
+5. Insert your Supabase project, auth user, and PowerSync project credentials into the `local.properties` file:
+
+```
+# local.properties
+sdk.dir=/path/to/android/sdk
+
+# Enter your PowerSync instance URL
+POWERSYNC_URL=https://foo.powersync.journeyapps.com
+# Enter your Supabase project's URL and public anon key (Project settings > API)
+SUPABASE_URL=https://foo.supabase.co
+SUPABASE_ANON_KEY=foo
+
+# Enter your Supabase auth user's details
+SUPABASE_USER_EMAIL=user@example.com
+SUPABASE_USER_PASSWORD=foo
+```
+
+## Run the App
+Choose a run configuration for the Android (`composeApp`) or iOS (`iosApp`) target in Android Studio and run it.
+
+
+
+## Project structure
+
+[//]: # (TODO)
+
+* `/composeApp` is for code that will be shared across your Compose Multiplatform applications.
+ It contains several subfolders:
+ - `commonMain` is for code that’s common for all targets.
+ - Other folders are for Kotlin code that will be compiled for only the platform indicated in the
+ folder name.
+ `iosMain` would be the right folder for such calls.
+
+* `/iosApp` contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
+ you need this entry point for your iOS app. This is also where you should add SwiftUI code for
+ your project.
\ No newline at end of file
diff --git a/demos/hello-powersync/build.gradle.kts b/demos/hello-powersync/build.gradle.kts
new file mode 100644
index 00000000..0ebd630a
--- /dev/null
+++ b/demos/hello-powersync/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ alias(projectLibs.plugins.androidApplication) apply false
+ alias(projectLibs.plugins.androidLibrary) apply false
+ alias(projectLibs.plugins.jetbrainsCompose) apply false
+ alias(projectLibs.plugins.kotlinMultiplatform) apply false
+ alias(projectLibs.plugins.cocoapods) apply false
+ alias(libs.plugins.buildKonfig) apply false
+}
\ No newline at end of file
diff --git a/demos/hello-powersync/composeApp/build.gradle.kts b/demos/hello-powersync/composeApp/build.gradle.kts
new file mode 100644
index 00000000..cc4aa438
--- /dev/null
+++ b/demos/hello-powersync/composeApp/build.gradle.kts
@@ -0,0 +1,128 @@
+import org.jetbrains.compose.ExperimentalComposeLibrary
+import java.util.Properties
+import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING
+
+plugins {
+ alias(projectLibs.plugins.kotlinMultiplatform)
+ alias(projectLibs.plugins.cocoapods)
+ alias(projectLibs.plugins.androidApplication)
+ alias(projectLibs.plugins.jetbrainsCompose)
+ alias(projectLibs.plugins.skie)
+ alias(libs.plugins.buildKonfig)
+}
+
+kotlin {
+ androidTarget()
+
+// iosX64() // uncomment to enable iOS x64
+ iosArm64()
+ iosSimulatorArm64()
+ cocoapods {
+ version = "1.0.0"
+ summary = "A shared library for Hello PowerSync app"
+ homepage = "none"
+ ios.deploymentTarget = "15.2"
+ podfile = project.file("../iosApp/Podfile")
+
+ pod("powersync-sqlite-core") {
+ linkOnly = true
+ }
+
+ framework {
+ baseName = "composeApp"
+ isStatic = true
+ export("com.powersync:core")
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ api("com.powersync:core")
+ api("com.powersync:connector-supabase")
+ implementation(projectLibs.bundles.sqldelight)
+ implementation(projectLibs.kotlinx.datetime)
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material)
+ implementation(compose.ui)
+ @OptIn(ExperimentalComposeLibrary::class)
+ implementation(compose.components.resources)
+ }
+
+ androidMain.dependencies {
+ implementation(projectLibs.compose.ui.tooling.preview)
+ implementation(projectLibs.androidx.activity.compose)
+ }
+ }
+}
+
+android {
+ namespace = "com.powersync.demos"
+ compileSdk = projectLibs.versions.android.compileSdk.get().toInt()
+
+ sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
+ sourceSets["main"].res.srcDirs("src/androidMain/res")
+ sourceSets["main"].resources.srcDirs("src/commonMain/resources")
+
+ defaultConfig {
+ applicationId = "com.powersync.demos"
+ minSdk = projectLibs.versions.android.minSdk.get().toInt()
+ targetSdk = projectLibs.versions.android.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = projectLibs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlin {
+ jvmToolchain(17)
+ }
+}
+
+val localProperties = Properties().apply {
+ try {
+ load(rootProject.file("local.properties").reader())
+ } catch (ignored: java.io.IOException) {
+ throw Error("local.properties file not found")
+ }
+}
+
+buildkonfig {
+ packageName = "com.powersync.demos"
+ objectName = "Config"
+
+ defaultConfigs {
+ fun stringConfigField(name: String) {
+ val propValue = localProperties.getProperty(name, "")
+ if (propValue.isBlank()) {
+ println("Warning: Property $name not found in local.properties")
+ } else {
+ buildConfigField(STRING, name, propValue)
+ }
+ }
+
+ stringConfigField("POWERSYNC_URL")
+ stringConfigField("SUPABASE_URL")
+ stringConfigField("SUPABASE_ANON_KEY")
+ stringConfigField("SUPABASE_USER_EMAIL")
+ stringConfigField("SUPABASE_USER_PASSWORD")
+ }
+}
\ No newline at end of file
diff --git a/demos/hello-powersync/composeApp/composeApp.podspec b/demos/hello-powersync/composeApp/composeApp.podspec
new file mode 100644
index 00000000..9cc7175d
--- /dev/null
+++ b/demos/hello-powersync/composeApp/composeApp.podspec
@@ -0,0 +1,50 @@
+Pod::Spec.new do |spec|
+ spec.name = 'composeApp'
+ spec.version = '1.0.0'
+ spec.homepage = 'none'
+ spec.source = { :http=> ''}
+ spec.authors = ''
+ spec.license = ''
+ spec.summary = 'A shared library for Hello PowerSync app'
+ spec.vendored_frameworks = 'build/cocoapods/framework/composeApp.framework'
+ spec.libraries = 'c++'
+ spec.ios.deployment_target = '15.2'
+ spec.dependency 'powersync-sqlite-core'
+
+ if !Dir.exist?('build/cocoapods/framework/composeApp.framework') || Dir.empty?('build/cocoapods/framework/composeApp.framework')
+ raise "
+
+ Kotlin framework 'composeApp' doesn't exist yet, so a proper Xcode project can't be generated.
+ 'pod install' should be executed after running ':generateDummyFramework' Gradle task:
+
+ ./gradlew :composeApp:generateDummyFramework
+
+ Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)"
+ end
+
+ spec.pod_target_xcconfig = {
+ 'KOTLIN_PROJECT_PATH' => ':composeApp',
+ 'PRODUCT_MODULE_NAME' => 'composeApp',
+ }
+
+ spec.script_phases = [
+ {
+ :name => 'Build composeApp',
+ :execution_position => :before_compile,
+ :shell_path => '/bin/sh',
+ :script => <<-SCRIPT
+ if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
+ echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
+ exit 0
+ fi
+ set -ev
+ REPO_ROOT="$PODS_TARGET_SRCROOT"
+ "$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
+ -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
+ -Pkotlin.native.cocoapods.archs="$ARCHS" \
+ -Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
+ SCRIPT
+ }
+ ]
+ spec.resources = ['build/compose/ios/composeApp/compose-resources']
+end
\ No newline at end of file
diff --git a/demos/hello-powersync/composeApp/src/androidMain/AndroidManifest.xml b/demos/hello-powersync/composeApp/src/androidMain/AndroidManifest.xml
new file mode 100644
index 00000000..f0692533
--- /dev/null
+++ b/demos/hello-powersync/composeApp/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/hello-powersync/composeApp/src/androidMain/kotlin/com/powersync/demos/MainActivity.kt b/demos/hello-powersync/composeApp/src/androidMain/kotlin/com/powersync/demos/MainActivity.kt
new file mode 100644
index 00000000..7a8e0af0
--- /dev/null
+++ b/demos/hello-powersync/composeApp/src/androidMain/kotlin/com/powersync/demos/MainActivity.kt
@@ -0,0 +1,38 @@
+package com.powersync.demos
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import com.powersync.DatabaseDriverFactory
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ App(PowerSync(DatabaseDriverFactory(this)))
+ }
+ }
+}
+
+
+@Preview
+@Composable
+fun ViewContentPreview() {
+ ViewContent("Preview", listOf(User("1", "John Doe", "john@example.com")), {}, {})
+}
+
+@Preview
+@Composable
+fun ViewContentPreview_ListItem() {
+ ListItem(cols = arrayOf("John Doe", "john@example.com"))
+}
+
+@Preview
+@Composable
+fun ViewContentPreview_MyButton() {
+ MyButton(label = "Preview Button", onClick = {})
+}
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/demos/hello-powersync/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_launcher_foreground.xml
rename to demos/hello-powersync/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/demos/hello-powersync/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_launcher_background.xml
rename to demos/hello-powersync/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
similarity index 79%
rename from app/src/main/res/mipmap-anydpi/ic_launcher.xml
rename to demos/hello-powersync/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
index 6f3b755b..eca70cfe 100644
--- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml
+++ b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -2,5 +2,4 @@
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
similarity index 79%
rename from app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
rename to demos/hello-powersync/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 6f3b755b..eca70cfe 100644
--- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
+++ b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -2,5 +2,4 @@
-
\ No newline at end of file
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..a571e600
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..61da551c
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..c41dd285
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..db5080a7
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..6dba46da
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..da31a871
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..15ac6817
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..b216f2d3
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..f25a4197
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..e96783cc
Binary files /dev/null and b/demos/hello-powersync/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/demos/hello-powersync/composeApp/src/androidMain/res/values/strings.xml b/demos/hello-powersync/composeApp/src/androidMain/res/values/strings.xml
new file mode 100644
index 00000000..f1f771db
--- /dev/null
+++ b/demos/hello-powersync/composeApp/src/androidMain/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ HelloPowerSync
+
\ No newline at end of file
diff --git a/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/App.kt b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/App.kt
new file mode 100644
index 00000000..ac522445
--- /dev/null
+++ b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/App.kt
@@ -0,0 +1,196 @@
+package com.powersync.demos
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.Divider
+import androidx.compose.material.LocalTextStyle
+import androidx.compose.material.Scaffold
+import androidx.compose.material.TopAppBar
+import androidx.compose.material.primarySurface
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+
+@Composable
+fun App(powerSync: PowerSync) {
+ var version by remember { mutableStateOf("Loading") }
+ val scope = rememberCoroutineScope()
+ val customers by powerSync.watchUsers().collectAsState(emptyList())
+
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colors.background
+ ) {
+ LaunchedEffect(powerSync) {
+ scope.launch {
+ version = """PowerSync version: ${powerSync.getPowersyncVersion()}"""
+ }
+ }
+
+ ViewContent(version,
+ users = customers,
+ onCreate = {
+ scope.launch {
+ val person = generateRandomPerson()
+ powerSync.createUser(person.first, person.second)
+ }
+ },
+ onDelete = {
+ scope.launch {
+ powerSync.deleteUser()
+ }
+ })
+ }
+ }
+}
+
+@Composable
+fun ViewContent(version: String, users: List, onCreate: () -> Unit, onDelete: () -> Unit) {
+ val layoutDirection = LocalLayoutDirection.current
+ Scaffold(
+ modifier = Modifier,
+ topBar = {
+ Box {
+ TopAppBar(
+ title = { Text("Hello PowerSync") },
+ )
+ }
+ },
+ content = { padding ->
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ contentPadding =
+ PaddingValues(
+ start = padding.calculateStartPadding(layoutDirection),
+ top = padding.calculateTopPadding() + 8.dp,
+ end = padding.calculateEndPadding(layoutDirection),
+ bottom = padding.calculateBottomPadding() + 80.dp
+ ),
+ ) {
+ item {
+
+ ListItem(
+ "Name",
+ "Email",
+ style = TextStyle(fontWeight = FontWeight.Bold),
+ modifier = Modifier.padding(bottom = 8.dp), divider = false
+ )
+ }
+ items(users) {
+ ListItem(it.name, it.email)
+ }
+
+ item {
+ Spacer(Modifier.height(24.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceAround
+ ) {
+ Column {
+ MyButton(label = "Create") {
+ onCreate()
+ }
+ }
+ Column {
+ MyButton(label = "Delete") {
+ onDelete()
+ }
+ }
+ }
+
+ }
+ }
+ // This box should be at the bottom of the screen
+ Box(modifier = Modifier.padding(24.dp).align(Alignment.BottomEnd)) {
+ Text(version)
+ }
+ }
+
+ },
+ contentColor = Color.Unspecified,
+ )
+}
+
+@Composable
+fun ListItem(
+ vararg cols: String,
+ modifier: Modifier = Modifier,
+ style: TextStyle? = null,
+ divider: Boolean = true,
+) {
+ Box(modifier = modifier) {
+ Box {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(modifier = Modifier.padding(horizontal = 16.dp)) {
+ cols.forEach {
+ Text(
+ it,
+ modifier = Modifier.weight(1f),
+ style = style ?: LocalTextStyle.current
+ )
+ }
+ }
+ }
+
+ if (divider) {
+ Divider(
+ color = Color.Black,
+ modifier = Modifier.align(Alignment.BottomStart)
+ )
+ }
+ }
+
+ }
+}
+
+
+@Composable
+fun MyButton(
+ modifier: Modifier = Modifier,
+ label: String,
+ onClick: () -> Unit,
+) {
+ Column(
+ modifier =
+ modifier
+ .clip(MaterialTheme.shapes.large)
+ .clickable(onClick = onClick).border(
+ width = 1.dp,
+ color = MaterialTheme.colors.primarySurface
+ )
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ label,
+ style = MaterialTheme.typography.button,
+ color = MaterialTheme.colors.primarySurface,
+ )
+ }
+}
diff --git a/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/AppSchema.kt b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/AppSchema.kt
new file mode 100644
index 00000000..c5846787
--- /dev/null
+++ b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/AppSchema.kt
@@ -0,0 +1,23 @@
+package com.powersync.demos
+
+import com.powersync.db.schema.Column
+import com.powersync.db.schema.Schema
+import com.powersync.db.schema.Table
+
+val AppSchema: Schema = Schema(
+ listOf(
+ Table(
+ "customers",
+ listOf(
+ Column.text("name"),
+ Column.text("email")
+ )
+ )
+ )
+)
+
+data class User(
+ val id: String,
+ val name: String,
+ val email: String
+)
\ No newline at end of file
diff --git a/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/PowerSync.kt b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/PowerSync.kt
new file mode 100644
index 00000000..30ce8dd3
--- /dev/null
+++ b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/PowerSync.kt
@@ -0,0 +1,67 @@
+package com.powersync.demos
+
+import com.powersync.DatabaseDriverFactory
+import com.powersync.PowerSyncBuilder
+import com.powersync.PowerSyncDatabase
+import com.powersync.connector.supabase.SupabaseConnector
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.runBlocking
+
+class PowerSync(
+ driverFactory: DatabaseDriverFactory,
+) {
+
+ private val connector = SupabaseConnector(
+ supabaseUrl = Config.SUPABASE_URL,
+ supabaseKey = Config.SUPABASE_ANON_KEY,
+ powerSyncEndpoint = Config.POWERSYNC_URL
+ )
+ private val database: PowerSyncDatabase =
+ PowerSyncBuilder.from(driverFactory, AppSchema).build();
+
+ init {
+ runBlocking {
+ try {
+ connector.login(Config.SUPABASE_USER_EMAIL, Config.SUPABASE_USER_PASSWORD)
+ } catch (e: Exception) {
+ println("Could not connect to Supabase, have you configured an auth user and set `SUPABASE_USER_EMAIL` and `SUPABASE_USER_PASSWORD`?\n Error: $e")
+ }
+ database.connect(connector)
+ }
+ }
+
+ suspend fun getPowersyncVersion(): String {
+ return database.getPowerSyncVersion()
+ }
+
+ fun watchUsers(): Flow> {
+ return database.watch("SELECT * FROM customers", mapper = { cursor ->
+ User(
+ id = cursor.getString(0)!!,
+ name = cursor.getString(1)!!,
+ email = cursor.getString(2)!!
+ )
+ })
+ }
+
+ suspend fun createUser(name: String, email: String) {
+ database.writeTransaction {
+ database.execute(
+ "INSERT INTO customers (id, name, email) VALUES (uuid(), ?, ?)",
+ listOf(name, email)
+ )
+ }
+ }
+
+ suspend fun deleteUser(id: String? = null) {
+ val targetId =
+ id ?: database.getOptional("SELECT id FROM customers LIMIT 1", mapper = { cursor ->
+ cursor.getString(0)!!
+ })
+ ?: return
+
+ database.writeTransaction {
+ database.execute("DELETE FROM customers WHERE id = ?", listOf(targetId))
+ }
+ }
+}
\ No newline at end of file
diff --git a/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/util.kt b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/util.kt
new file mode 100644
index 00000000..eaaddcf0
--- /dev/null
+++ b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/util.kt
@@ -0,0 +1,23 @@
+package com.powersync.demos
+
+import kotlinx.datetime.Clock
+import kotlin.random.Random
+
+fun generateRandomPerson(): Pair {
+ val names =
+ listOf("John", "Jane", "Bob", "Alice", "Charlie", "Megan", "Mike", "Sally", "Joe", "Jill")
+ val domains = listOf("gmail.com", "yahoo.com", "hotmail.com", "outlook.com")
+
+ val random = Random(
+ Clock.System.now().toEpochMilliseconds()
+ )
+
+ val num = random.nextInt(names.size)
+
+ val name = names[random.nextInt(names.size)]
+ val domain = domains[random.nextInt(domains.size)]
+
+ val email = "${name.lowercase()}${num + 1}@$domain"
+
+ return Pair(name, email)
+}
\ No newline at end of file
diff --git a/demos/hello-powersync/composeApp/src/commonMain/resources/compose-multiplatform.xml b/demos/hello-powersync/composeApp/src/commonMain/resources/compose-multiplatform.xml
new file mode 100644
index 00000000..d7bf7955
--- /dev/null
+++ b/demos/hello-powersync/composeApp/src/commonMain/resources/compose-multiplatform.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
diff --git a/demos/hello-powersync/composeApp/src/iosMain/kotlin/com/powersync/demos/MainViewController.kt b/demos/hello-powersync/composeApp/src/iosMain/kotlin/com/powersync/demos/MainViewController.kt
new file mode 100644
index 00000000..59cc5751
--- /dev/null
+++ b/demos/hello-powersync/composeApp/src/iosMain/kotlin/com/powersync/demos/MainViewController.kt
@@ -0,0 +1,6 @@
+package com.powersync.demos
+
+import androidx.compose.ui.window.ComposeUIViewController
+import com.powersync.DatabaseDriverFactory
+
+fun MainViewController() = ComposeUIViewController { App(PowerSync(DatabaseDriverFactory())) }
diff --git a/demos/hello-powersync/gradle.properties b/demos/hello-powersync/gradle.properties
new file mode 100644
index 00000000..6e5e786f
--- /dev/null
+++ b/demos/hello-powersync/gradle.properties
@@ -0,0 +1,18 @@
+kotlin.code.style=official
+
+#Gradle
+org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M"
+
+
+#Android
+android.nonTransitiveRClass=true
+android.useAndroidX=true
+
+#MPP
+kotlin.mpp.androidSourceSetLayoutVersion=2
+kotlin.mpp.enableCInteropCommonization=true
+kotlin.native.cacheKind.iosSimulatorArm64=none
+compose.kotlin.native.manageCacheKind=false
+
+#Development
+development=true
\ No newline at end of file
diff --git a/demos/hello-powersync/gradle/libs.versions.toml b/demos/hello-powersync/gradle/libs.versions.toml
new file mode 100644
index 00000000..002fda01
--- /dev/null
+++ b/demos/hello-powersync/gradle/libs.versions.toml
@@ -0,0 +1,7 @@
+[versions]
+buildKonfig = "0.15.1"
+
+[libraries]
+
+[plugins]
+buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildKonfig" }
\ No newline at end of file
diff --git a/demos/hello-powersync/gradle/wrapper/gradle-wrapper.jar b/demos/hello-powersync/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..033e24c4
Binary files /dev/null and b/demos/hello-powersync/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/demos/hello-powersync/gradle/wrapper/gradle-wrapper.properties b/demos/hello-powersync/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..1af9e093
--- /dev/null
+++ b/demos/hello-powersync/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/demos/hello-powersync/gradlew b/demos/hello-powersync/gradlew
new file mode 100755
index 00000000..fcb6fca1
--- /dev/null
+++ b/demos/hello-powersync/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/demos/hello-powersync/gradlew.bat b/demos/hello-powersync/gradlew.bat
new file mode 100644
index 00000000..93e3f59f
--- /dev/null
+++ b/demos/hello-powersync/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/demos/hello-powersync/iosApp/Podfile b/demos/hello-powersync/iosApp/Podfile
new file mode 100644
index 00000000..c7373f79
--- /dev/null
+++ b/demos/hello-powersync/iosApp/Podfile
@@ -0,0 +1,6 @@
+source 'https://cdn.cocoapods.org'
+target 'iosApp' do
+ use_frameworks!
+ platform :ios, '16.0'
+ pod 'composeApp', :path => '../composeApp'
+end
\ No newline at end of file
diff --git a/demos/hello-powersync/iosApp/Podfile.lock b/demos/hello-powersync/iosApp/Podfile.lock
new file mode 100644
index 00000000..2f512b40
--- /dev/null
+++ b/demos/hello-powersync/iosApp/Podfile.lock
@@ -0,0 +1,23 @@
+PODS:
+ - composeApp (1.0.0):
+ - powersync-sqlite-core
+ - powersync-sqlite-core (0.1.6)
+
+DEPENDENCIES:
+ - composeApp (from `../composeApp`)
+
+SPEC REPOS:
+ trunk:
+ - powersync-sqlite-core
+
+EXTERNAL SOURCES:
+ composeApp:
+ :path: "../composeApp"
+
+SPEC CHECKSUMS:
+ composeApp: d4907701ff82fa92100d8460385b83c9b30955ba
+ powersync-sqlite-core: 4c38c8f470f6dca61346789fd5436a6826d1e3dd
+
+PODFILE CHECKSUM: 4680f51fbb293d1385fb2467ada435cc1f16ab3d
+
+COCOAPODS: 1.14.3
diff --git a/demos/hello-powersync/iosApp/iosApp.xcodeproj/project.pbxproj b/demos/hello-powersync/iosApp/iosApp.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..e0452b93
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -0,0 +1,419 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 50;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
+ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
+ 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
+ 5B28882998C9AF022DAD2648 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 19C4AFE04F499A35C5A11951 /* Pods_iosApp.framework */; };
+ 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
+ 19C4AFE04F499A35C5A11951 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; };
+ 3F8B814AA2E52E4B79904725 /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; };
+ 7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 77077E58F0E7BC2367C87092 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ EF8A45596C10845FBC80C40F /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 5B28882998C9AF022DAD2648 /* Pods_iosApp.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 058557D7273AAEEB004C7B11 /* Preview Content */ = {
+ isa = PBXGroup;
+ children = (
+ 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */,
+ );
+ path = "Preview Content";
+ sourceTree = "";
+ };
+ 7555FF72242A565900829871 = {
+ isa = PBXGroup;
+ children = (
+ 7555FF7D242A565900829871 /* iosApp */,
+ 7555FF7C242A565900829871 /* Products */,
+ DB96CDD0BBEDF8F2E237A8B6 /* Pods */,
+ BE33A8774A8103F1A69AC4B1 /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 7555FF7C242A565900829871 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 7555FF7B242A565900829871 /* iosApp.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 7555FF7D242A565900829871 /* iosApp */ = {
+ isa = PBXGroup;
+ children = (
+ 058557BA273AAA24004C7B11 /* Assets.xcassets */,
+ 7555FF82242A565900829871 /* ContentView.swift */,
+ 7555FF8C242A565B00829871 /* Info.plist */,
+ 2152FB032600AC8F00CF470E /* iOSApp.swift */,
+ 058557D7273AAEEB004C7B11 /* Preview Content */,
+ );
+ path = iosApp;
+ sourceTree = "";
+ };
+ BE33A8774A8103F1A69AC4B1 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 19C4AFE04F499A35C5A11951 /* Pods_iosApp.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ DB96CDD0BBEDF8F2E237A8B6 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 77077E58F0E7BC2367C87092 /* Pods-iosApp.debug.xcconfig */,
+ 3F8B814AA2E52E4B79904725 /* Pods-iosApp.release.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 7555FF7A242A565900829871 /* iosApp */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
+ buildPhases = (
+ FD6F5E7B50FDDEA472B254A2 /* [CP] Check Pods Manifest.lock */,
+ 7555FF77242A565900829871 /* Sources */,
+ 7555FF79242A565900829871 /* Resources */,
+ EF8A45596C10845FBC80C40F /* Frameworks */,
+ CFB5C47A514C8D54DDE968FF /* [CP] Copy Pods Resources */,
+ BDEF1F2128374088F98D10FB /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = iosApp;
+ productName = iosApp;
+ productReference = 7555FF7B242A565900829871 /* iosApp.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 7555FF73242A565900829871 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ LastSwiftUpdateCheck = 1130;
+ LastUpgradeCheck = 1130;
+ ORGANIZATIONNAME = orgName;
+ TargetAttributes = {
+ 7555FF7A242A565900829871 = {
+ CreatedOnToolsVersion = 11.3.1;
+ };
+ };
+ };
+ buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 7555FF72242A565900829871;
+ productRefGroup = 7555FF7C242A565900829871 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 7555FF7A242A565900829871 /* iosApp */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 7555FF79242A565900829871 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */,
+ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ BDEF1F2128374088F98D10FB /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ CFB5C47A514C8D54DDE968FF /* [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;
+ };
+ FD6F5E7B50FDDEA472B254A2 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ 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;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 7555FF77242A565900829871 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */,
+ 7555FF83242A565900829871 /* ContentView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 7555FFA3242A565B00829871 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 7555FFA4242A565B00829871 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 7555FFA6242A565B00829871 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 77077E58F0E7BC2367C87092 /* Pods-iosApp.debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
+ ENABLE_PREVIEWS = YES;
+ INFOPLIST_FILE = iosApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 7555FFA7242A565B00829871 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 3F8B814AA2E52E4B79904725 /* Pods-iosApp.release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
+ ENABLE_PREVIEWS = YES;
+ INFOPLIST_FILE = iosApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7555FFA3242A565B00829871 /* Debug */,
+ 7555FFA4242A565B00829871 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7555FFA6242A565B00829871 /* Debug */,
+ 7555FFA7242A565B00829871 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 7555FF73242A565900829871 /* Project object */;
+}
diff --git a/demos/hello-powersync/iosApp/iosApp.xcworkspace/contents.xcworkspacedata b/demos/hello-powersync/iosApp/iosApp.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..c009e7d7
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/demos/hello-powersync/iosApp/iosApp.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/demos/hello-powersync/iosApp/iosApp.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000..ee7e3ca0
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..8edf56e7
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "app-icon-1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
new file mode 100644
index 00000000..53fc536f
Binary files /dev/null and b/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ
diff --git a/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/Contents.json b/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..4aa7c535
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/demos/hello-powersync/iosApp/iosApp/ContentView.swift b/demos/hello-powersync/iosApp/iosApp/ContentView.swift
new file mode 100644
index 00000000..cb095b30
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp/ContentView.swift
@@ -0,0 +1,21 @@
+import UIKit
+import SwiftUI
+import composeApp
+
+struct ComposeView: UIViewControllerRepresentable {
+ func makeUIViewController(context: Context) -> UIViewController {
+ MainViewControllerKt.MainViewController()
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+}
+
+struct ContentView: View {
+ var body: some View {
+ ComposeView()
+ .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
+ }
+}
+
+
+
diff --git a/demos/hello-powersync/iosApp/iosApp/Info.plist b/demos/hello-powersync/iosApp/iosApp/Info.plist
new file mode 100644
index 00000000..412e3781
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp/Info.plist
@@ -0,0 +1,50 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UILaunchScreen
+
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/demos/hello-powersync/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/demos/hello-powersync/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000..4aa7c535
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/demos/hello-powersync/iosApp/iosApp/iOSApp.swift b/demos/hello-powersync/iosApp/iosApp/iOSApp.swift
new file mode 100644
index 00000000..0648e860
--- /dev/null
+++ b/demos/hello-powersync/iosApp/iosApp/iOSApp.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+@main
+struct iOSApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
\ No newline at end of file
diff --git a/demos/hello-powersync/settings.gradle.kts b/demos/hello-powersync/settings.gradle.kts
new file mode 100644
index 00000000..b21ea40c
--- /dev/null
+++ b/demos/hello-powersync/settings.gradle.kts
@@ -0,0 +1,38 @@
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+pluginManagement {
+ repositories {
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ google()
+ gradlePluginPortal()
+ mavenCentral()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ maven("https://jitpack.io")
+ }
+ versionCatalogs {
+ create("projectLibs") {
+ from(files("../../gradle/libs.versions.toml"))
+ }
+ }
+}
+
+rootProject.name = "hello-powersync"
+
+include(":composeApp")
+
+includeBuild("../..") {
+ dependencySubstitution {
+ substitute(module("com.powersync:core"))
+ .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/supabase-todolist/.gitignore b/demos/supabase-todolist/.gitignore
new file mode 100644
index 00000000..e6c9b613
--- /dev/null
+++ b/demos/supabase-todolist/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/
+.DS_Store
+build/
+/captures
+.externalNativeBuild
+.cxx
+iosApp/Podfile.lock
+iosApp/Pods/*
+iosApp/iosApp.xcworkspace/*
+iosApp/iosApp.xcodeproj/*
+!iosApp/iosApp.xcodeproj/project.pbxproj
+shared/shared.podspec
\ No newline at end of file
diff --git a/demos/supabase-todolist/README.md b/demos/supabase-todolist/README.md
new file mode 100644
index 00000000..d2115c69
--- /dev/null
+++ b/demos/supabase-todolist/README.md
@@ -0,0 +1,28 @@
+# PowerSync + Supabase Kotlin Multiplatform Demo: Todo List App
+
+*** This demo app is a work in progress. It can be run, but certain functionality does not yet work. ***
+
+This demo app originally was cloned from [JetBrains' TodoApp (lite version)](https://github.com/JetBrains/compose-multiplatform/tree/master/examples/todoapp-lite).
+
+It is a simple to-do list application demonstrating use of the PowerSync Kotlin Mutiplatform SDK together
+with [Supabase](https://supabase.com/) in a basic Kotlin Multiplatform Compose App.
+
+Supported KMP targets: Android and iOS.
+
+## Setting up your development environment
+To setup your environment, please consult [these instructions](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-setup.html).
+
+## Set up Supabase project
+
+Instructions will follow soon.
+
+## Set up PowerSync instance
+
+Instructions will follow soon.
+
+## Set up project in Android Studio
+
+Instructions will follow soon.
+
+## Run the app
+Choose a run configuration for the Android or iOS target in Android Studio and run it.
diff --git a/demos/supabase-todolist/androidApp/build.gradle.kts b/demos/supabase-todolist/androidApp/build.gradle.kts
new file mode 100644
index 00000000..1498d3e8
--- /dev/null
+++ b/demos/supabase-todolist/androidApp/build.gradle.kts
@@ -0,0 +1,36 @@
+plugins {
+ kotlin("multiplatform")
+ id("com.android.application")
+ id("org.jetbrains.compose")
+}
+
+kotlin {
+ androidTarget()
+ sourceSets {
+ val androidMain by getting {
+ dependencies {
+ implementation(project(":shared"))
+ implementation(compose.material)
+ }
+ }
+ }
+}
+
+android {
+ namespace = "com.powersync.demos"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+ defaultConfig {
+ applicationId = "com.powersync.demos.TodoAppLite"
+ minSdk = libs.versions.android.minSdk.get().toInt()
+ targetSdk = libs.versions.android.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlin {
+ jvmToolchain(libs.versions.java.get().toInt())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/demos/supabase-todolist/androidApp/src/androidMain/AndroidManifest.xml
similarity index 63%
rename from app/src/main/AndroidManifest.xml
rename to demos/supabase-todolist/androidApp/src/androidMain/AndroidManifest.xml
index 0d497d4e..89050814 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/demos/supabase-todolist/androidApp/src/androidMain/AndroidManifest.xml
@@ -1,29 +1,25 @@
-
+
+
+ android:theme="@style/Theme.AppCompat.Light.NoActionBar"
+ android:enableOnBackInvokedCallback="true">
+
+
+ android:exported="true">
-
-
-
\ No newline at end of file
+
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
new file mode 100644
index 00000000..de165a77
--- /dev/null
+++ b/demos/supabase-todolist/androidApp/src/androidMain/kotlin/com/powersync/demos/MainActivity.kt
@@ -0,0 +1,24 @@
+package com.powersync.demos
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import com.powersync.DatabaseDriverFactory
+
+class MainActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ MaterialTheme {
+ Surface(color = MaterialTheme.colors.background) {
+ MainView(DatabaseDriverFactory(this))
+ }
+ }
+ }
+ }
+}
+
diff --git a/demos/supabase-todolist/build.gradle.kts b/demos/supabase-todolist/build.gradle.kts
new file mode 100644
index 00000000..361cc397
--- /dev/null
+++ b/demos/supabase-todolist/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.androidLibrary) apply false
+ alias(libs.plugins.jetbrainsCompose) apply false
+ alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.cocoapods) apply false
+}
diff --git a/demos/supabase-todolist/desktopApp/build.gradle.kts b/demos/supabase-todolist/desktopApp/build.gradle.kts
new file mode 100644
index 00000000..3e35996e
--- /dev/null
+++ b/demos/supabase-todolist/desktopApp/build.gradle.kts
@@ -0,0 +1,35 @@
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.jetbrainsCompose)
+}
+
+kotlin {
+ jvm {}
+ sourceSets {
+ jvmMain.dependencies {
+ implementation(compose.desktop.currentOs)
+ implementation(project(":shared"))
+ }
+ }
+}
+
+compose.desktop {
+ application {
+ mainClass = "com.powersync.demos.MainKt"
+
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+ packageName = "TodoApp Lite"
+ packageVersion = "1.0.0"
+
+ windows {
+ menuGroup = "Compose Examples"
+ // see https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html
+ upgradeUuid = "5ac63736-d8c7-4a65-a66b-6870df88ddfe"
+ }
+ }
+ }
+}
+
diff --git a/demos/supabase-todolist/desktopApp/src/jvmMain/kotlin/com/powersync/demos/Main.kt b/demos/supabase-todolist/desktopApp/src/jvmMain/kotlin/com/powersync/demos/Main.kt
new file mode 100644
index 00000000..483df5c0
--- /dev/null
+++ b/demos/supabase-todolist/desktopApp/src/jvmMain/kotlin/com/powersync/demos/Main.kt
@@ -0,0 +1,26 @@
+package com.powersync.demos
+
+import MainView
+import androidx.compose.material.MaterialTheme
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+
+
+fun main() {
+ application {
+ Window(
+ onCloseRequest = ::exitApplication,
+ title = "TodoApp Lite",
+ state = rememberWindowState(
+ position = WindowPosition(alignment = Alignment.Center),
+ ),
+ ) {
+ MaterialTheme {
+ MainView()
+ }
+ }
+ }
+}
diff --git a/demos/supabase-todolist/gradle.properties b/demos/supabase-todolist/gradle.properties
new file mode 100644
index 00000000..a6ee93c4
--- /dev/null
+++ b/demos/supabase-todolist/gradle.properties
@@ -0,0 +1,10 @@
+kotlin.code.style=official
+xcodeproj=./iosApp
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx3g
+org.jetbrains.compose.experimental.jscanvas.enabled=true
+org.jetbrains.compose.experimental.macos.enabled=true
+kotlin.mpp.androidSourceSetLayoutVersion=2
+kotlin.native.useEmbeddableCompilerJar=true
+# Enable kotlin/native experimental memory model
+kotlin.native.binary.memoryModel=experimental
diff --git a/demos/supabase-todolist/gradle/libs.versions.toml b/demos/supabase-todolist/gradle/libs.versions.toml
new file mode 100644
index 00000000..6e3e419e
--- /dev/null
+++ b/demos/supabase-todolist/gradle/libs.versions.toml
@@ -0,0 +1,84 @@
+[versions]
+## SDK Versions
+android-minSdk = "24"
+android-targetSdk = "34"
+android-compileSdk = "34"
+java = "17"
+
+# Dependencies
+powersync = "0.0.1-ALPHA2"
+powersync-core = "0.1.6"
+
+kotlin = "1.9.22"
+coroutines = "1.7.3"
+kotlinx-datetime = "0.5.0"
+kotlinx-io = "0.3.0"
+ktor = "2.3.7"
+uuid = "0.8.2"
+buildKonfig = "0.15.1"
+
+supabase = "2.0.4"
+junit = "4.13.2"
+
+compose = "1.5.12"
+compose-preview = "1.6.1"
+compose-compiler = "1.5.9"
+
+# plugins
+android-gradle-plugin = "8.2.2"
+skie = "0.6.1"
+
+# Sample - Android
+androidx-core = "1.12.0"
+androidx-activity-compose = "1.8.2"
+
+androidx-appcompat = "1.6.1"
+androidx-espresso-core = "3.5.1"
+androidx-material = "1.11.0"
+androidx-test-junit = "1.1.5"
+
+[libraries]
+powersync-core = { module = "com.powersync:core", version.ref = "powersync" }
+powersync-connector-supabase = { module = "com.powersync:connector-supabase", version.ref = "powersync" }
+
+powersync-sqlite-core = { module = "co.powersync:powersync-sqlite-core", version.ref = "powersync-core" }
+
+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" }
+kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
+kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
+
+kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
+kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
+
+uuid = { module = "com.benasher44:uuid", version.ref = "uuid" }
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-ios = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", 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" }
+
+supabase-client = { module = "io.github.jan-tennert.supabase:postgrest-kt", version.ref = "supabase" }
+supabase-gotrue = { module = "io.github.jan-tennert.supabase:gotrue-kt", version.ref = "supabase" }
+
+androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
+androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" }
+compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose-preview" }
+compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose-compiler" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "android-gradle-plugin" }
+androidLibrary = { id = "com.android.library", version.ref = "android-gradle-plugin" }
+jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose" }
+cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+skie = { id = "co.touchlab.skie", version.ref = "skie" }
+buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildKonfig" }
+
+[bundles]
diff --git a/demos/supabase-todolist/gradle/wrapper/gradle-wrapper.jar b/demos/supabase-todolist/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..249e5832
Binary files /dev/null and b/demos/supabase-todolist/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/demos/supabase-todolist/gradle/wrapper/gradle-wrapper.properties b/demos/supabase-todolist/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..3fa8f862
--- /dev/null
+++ b/demos/supabase-todolist/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/demos/supabase-todolist/gradlew b/demos/supabase-todolist/gradlew
new file mode 100755
index 00000000..a69d9cb6
--- /dev/null
+++ b/demos/supabase-todolist/gradlew
@@ -0,0 +1,240 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/demos/supabase-todolist/gradlew.bat b/demos/supabase-todolist/gradlew.bat
new file mode 100644
index 00000000..f127cfd4
--- /dev/null
+++ b/demos/supabase-todolist/gradlew.bat
@@ -0,0 +1,91 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/demos/supabase-todolist/iosApp/Configuration/Config.xcconfig b/demos/supabase-todolist/iosApp/Configuration/Config.xcconfig
new file mode 100644
index 00000000..9624618e
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/Configuration/Config.xcconfig
@@ -0,0 +1,3 @@
+TEAM_ID=
+BUNDLE_ID=com.powersync.demos.TodoAppLite
+APP_NAME=TodoAppLite
diff --git a/demos/supabase-todolist/iosApp/Podfile b/demos/supabase-todolist/iosApp/Podfile
new file mode 100644
index 00000000..e899236f
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/Podfile
@@ -0,0 +1,6 @@
+source 'https://cdn.cocoapods.org'
+target 'iosApp' do
+ use_frameworks!
+ platform :ios, '16.0'
+ pod 'shared', :path => '../shared'
+end
\ No newline at end of file
diff --git a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..d43a05e0
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -0,0 +1,466 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 50;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
+ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
+ 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
+ 23AC468AA9C00D6E8E6EBFC2 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E2E7A0C9AA43F2F149CE87F /* Pods_iosApp.framework */; };
+ 3251B1422AA8DC67001521C0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3251B1412AA8DC67001521C0 /* ContentView.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
+ 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; };
+ 3251B1412AA8DC67001521C0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 3E2E7A0C9AA43F2F149CE87F /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 413D5213469B8CAF0EEA97AB /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; };
+ 7555FF7B242A565900829871 /* TodoAppLite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TodoAppLite.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; };
+ C149F9279C2935745DDFBC72 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ F85CB1118929364A9C6EFABC /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 23AC468AA9C00D6E8E6EBFC2 /* Pods_iosApp.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 058557D7273AAEEB004C7B11 /* Preview Content */ = {
+ isa = PBXGroup;
+ children = (
+ 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */,
+ );
+ path = "Preview Content";
+ sourceTree = "";
+ };
+ 1CBADE2FAEA0F180A50E7570 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ C149F9279C2935745DDFBC72 /* Pods-iosApp.debug.xcconfig */,
+ 413D5213469B8CAF0EEA97AB /* Pods-iosApp.release.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
+ 7555FF72242A565900829871 = {
+ isa = PBXGroup;
+ children = (
+ AB1DB47929225F7C00F7AF9C /* Configuration */,
+ 7555FF7D242A565900829871 /* iosApp */,
+ 7555FF7C242A565900829871 /* Products */,
+ 1CBADE2FAEA0F180A50E7570 /* Pods */,
+ DA7F5E38B0CCD293C453CEEA /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 7555FF7C242A565900829871 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 7555FF7B242A565900829871 /* TodoAppLite.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 7555FF7D242A565900829871 /* iosApp */ = {
+ isa = PBXGroup;
+ children = (
+ 058557BA273AAA24004C7B11 /* Assets.xcassets */,
+ 7555FF8C242A565B00829871 /* Info.plist */,
+ 2152FB032600AC8F00CF470E /* iOSApp.swift */,
+ 058557D7273AAEEB004C7B11 /* Preview Content */,
+ 3251B1412AA8DC67001521C0 /* ContentView.swift */,
+ );
+ path = iosApp;
+ sourceTree = "";
+ };
+ AB1DB47929225F7C00F7AF9C /* Configuration */ = {
+ isa = PBXGroup;
+ children = (
+ AB3632DC29227652001CCB65 /* Config.xcconfig */,
+ );
+ path = Configuration;
+ sourceTree = "";
+ };
+ DA7F5E38B0CCD293C453CEEA /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 3E2E7A0C9AA43F2F149CE87F /* Pods_iosApp.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 7555FF7A242A565900829871 /* iosApp */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
+ buildPhases = (
+ 5965A06ACB737B46CD19C2D1 /* [CP] Check Pods Manifest.lock */,
+ 0548D1B92A8FD622002E7F63 /* Compile Kotlin */,
+ 7555FF77242A565900829871 /* Sources */,
+ 7555FF79242A565900829871 /* Resources */,
+ F85CB1118929364A9C6EFABC /* Frameworks */,
+ 3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */,
+ 3114824168D992F9E0D6D9F4 /* [CP] Copy Pods Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = iosApp;
+ productName = iosApp;
+ productReference = 7555FF7B242A565900829871 /* TodoAppLite.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 7555FF73242A565900829871 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ LastSwiftUpdateCheck = 1130;
+ LastUpgradeCheck = 1130;
+ ORGANIZATIONNAME = orgName;
+ TargetAttributes = {
+ 7555FF7A242A565900829871 = {
+ CreatedOnToolsVersion = 11.3.1;
+ };
+ };
+ };
+ buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 7555FF72242A565900829871;
+ productRefGroup = 7555FF7C242A565900829871 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 7555FF7A242A565900829871 /* iosApp */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 7555FF79242A565900829871 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */,
+ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 0548D1B92A8FD622002E7F63 /* Compile Kotlin */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Compile Kotlin";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "cd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n";
+ };
+ 3114824168D992F9E0D6D9F4 /* [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;
+ };
+ 3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 5965A06ACB737B46CD19C2D1 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ 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;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 7555FF77242A565900829871 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3251B1422AA8DC67001521C0 /* ContentView.swift in Sources */,
+ 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 7555FFA3242A565B00829871 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.1;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 7555FFA4242A565B00829871 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.1;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 7555FFA6242A565B00829871 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = C149F9279C2935745DDFBC72 /* Pods-iosApp.debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
+ DEVELOPMENT_TEAM = "${TEAM_ID}";
+ ENABLE_PREVIEWS = YES;
+ INFOPLIST_FILE = iosApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.1;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-framework",
+ "shared\n",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
+ PRODUCT_NAME = "${APP_NAME}";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 7555FFA7242A565B00829871 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 413D5213469B8CAF0EEA97AB /* Pods-iosApp.release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
+ DEVELOPMENT_TEAM = "${TEAM_ID}";
+ ENABLE_PREVIEWS = YES;
+ INFOPLIST_FILE = iosApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.1;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-framework",
+ "shared\n",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
+ PRODUCT_NAME = "${APP_NAME}";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7555FFA3242A565B00829871 /* Debug */,
+ 7555FFA4242A565B00829871 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7555FFA6242A565B00829871 /* Debug */,
+ 7555FFA7242A565B00829871 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 7555FF73242A565900829871 /* Project object */;
+}
diff --git a/demos/supabase-todolist/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/demos/supabase-todolist/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000..ee7e3ca0
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/demos/supabase-todolist/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/demos/supabase-todolist/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..fb88a396
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,98 @@
+{
+ "images" : [
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "76x76"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "76x76"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "83.5x83.5"
+ },
+ {
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/demos/supabase-todolist/iosApp/iosApp/Assets.xcassets/Contents.json b/demos/supabase-todolist/iosApp/iosApp/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..4aa7c535
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/iosApp/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/demos/supabase-todolist/iosApp/iosApp/ContentView.swift b/demos/supabase-todolist/iosApp/iosApp/ContentView.swift
new file mode 100644
index 00000000..8ccaf13b
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/iosApp/ContentView.swift
@@ -0,0 +1,19 @@
+import UIKit
+import SwiftUI
+import shared
+
+struct ContentView: View {
+ var body: some View {
+ ComposeView()
+ .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
+ }
+}
+
+struct ComposeView: UIViewControllerRepresentable {
+ func makeUIViewController(context: Context) -> UIViewController {
+ Main_iosKt.MainViewController()
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
+ }
+}
diff --git a/demos/supabase-todolist/iosApp/iosApp/Info.plist b/demos/supabase-todolist/iosApp/iosApp/Info.plist
new file mode 100644
index 00000000..9a269f5e
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/iosApp/Info.plist
@@ -0,0 +1,48 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UILaunchScreen
+
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/demos/supabase-todolist/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/demos/supabase-todolist/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000..4aa7c535
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/demos/supabase-todolist/iosApp/iosApp/iOSApp.swift b/demos/supabase-todolist/iosApp/iosApp/iOSApp.swift
new file mode 100644
index 00000000..927e0b97
--- /dev/null
+++ b/demos/supabase-todolist/iosApp/iosApp/iOSApp.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+@main
+struct iOSApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/demos/supabase-todolist/run-configurations.png b/demos/supabase-todolist/run-configurations.png
new file mode 100644
index 00000000..d859ff08
Binary files /dev/null and b/demos/supabase-todolist/run-configurations.png differ
diff --git a/demos/supabase-todolist/screenshots/todoapplite.png b/demos/supabase-todolist/screenshots/todoapplite.png
new file mode 100644
index 00000000..39702b6b
Binary files /dev/null and b/demos/supabase-todolist/screenshots/todoapplite.png differ
diff --git a/demos/supabase-todolist/settings.gradle.kts b/demos/supabase-todolist/settings.gradle.kts
new file mode 100644
index 00000000..15f5ad52
--- /dev/null
+++ b/demos/supabase-todolist/settings.gradle.kts
@@ -0,0 +1,54 @@
+import java.util.Properties
+
+pluginManagement {
+ repositories {
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ google()
+ gradlePluginPortal()
+ mavenCentral()
+ }
+}
+
+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")
+ maven {
+ url = uri("https://maven.pkg.github.com/powersync-ja/powersync-kotlin")
+ credentials {
+ username = localProperties.getProperty("GITHUB_USERNAME", "")
+ password = localProperties.getProperty("GITHUB_TOKEN", "")
+ }
+ }
+ }
+}
+
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version ("0.4.0")
+}
+
+rootProject.name = "supabase-todolist"
+
+include(":androidApp")
+include(":shared")
+//include(":desktopApp")
+
+includeBuild("../..") {
+ dependencySubstitution {
+ substitute(module("com.powersync:core"))
+ .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")
+ }
+}
\ No newline at end of file
diff --git a/demos/supabase-todolist/shared/build.gradle.kts b/demos/supabase-todolist/shared/build.gradle.kts
new file mode 100644
index 00000000..47b8af85
--- /dev/null
+++ b/demos/supabase-todolist/shared/build.gradle.kts
@@ -0,0 +1,108 @@
+import java.util.Properties
+import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.androidLibrary)
+ alias(libs.plugins.jetbrainsCompose)
+ alias(libs.plugins.cocoapods)
+ alias(libs.plugins.buildKonfig)
+}
+
+version = "1.0-SNAPSHOT"
+
+kotlin {
+ androidTarget()
+
+// jvm()
+ iosX64()
+ iosArm64()
+ iosSimulatorArm64()
+
+ cocoapods {
+ version = "1.0.0"
+ summary = "A shared library for TodoAppLite PowerSync app"
+ homepage = "none"
+ ios.deploymentTarget = "14.1"
+ podfile = project.file("../iosApp/Podfile")
+ pod("powersync-sqlite-core") {
+ linkOnly = true
+ }
+
+ framework {
+ baseName = "shared"
+ isStatic = true
+ export(libs.powersync.core)
+ }
+ }
+ sourceSets {
+ commonMain.dependencies {
+ api(libs.powersync.core)
+ api(libs.powersync.connector.supabase)
+ implementation(libs.uuid)
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material)
+ @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
+ implementation(compose.components.resources)
+ }
+ androidMain.dependencies {
+ api(libs.androidx.activity.compose)
+ api(libs.androidx.appcompat)
+ api(libs.androidx.core)
+ }
+
+// jvmMain.dependencies {
+// implementation(compose.desktop.common)
+// }
+ }
+}
+
+android {
+ namespace = "com.powersync.demos"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+ sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
+ sourceSets["main"].res.srcDirs("src/androidMain/res")
+ sourceSets["main"].resources.srcDirs("src/commonMain/resources")
+
+ defaultConfig {
+ minSdk = libs.versions.android.minSdk.get().toInt()
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlin {
+ jvmToolchain(libs.versions.java.get().toInt())
+ }
+}
+
+val localProperties = Properties().apply {
+ try {
+ load(rootProject.file("local.properties").reader())
+ } catch (ignored: java.io.IOException) {
+ throw Error("local.properties file not found")
+ }
+}
+
+buildkonfig {
+ packageName = "com.powersync.demos"
+ objectName = "Config"
+
+ defaultConfigs {
+ fun stringConfigField(name: String) {
+ val propValue = localProperties.getProperty(name, "")
+ if (propValue.isBlank()) {
+ println("Warning: Property $name not found in local.properties")
+ } else {
+ buildConfigField(STRING, name, propValue)
+ }
+ }
+
+ stringConfigField("POWERSYNC_URL")
+ stringConfigField("SUPABASE_URL")
+ stringConfigField("SUPABASE_ANON_KEY")
+ stringConfigField("SUPABASE_USER_EMAIL")
+ stringConfigField("SUPABASE_USER_PASSWORD")
+ }
+}
diff --git a/demos/supabase-todolist/shared/src/androidMain/AndroidManifest.xml b/demos/supabase-todolist/shared/src/androidMain/AndroidManifest.xml
new file mode 100644
index 00000000..8072ee00
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/demos/supabase-todolist/shared/src/androidMain/kotlin/com/powersync/demos/Utils.kt b/demos/supabase-todolist/shared/src/androidMain/kotlin/com/powersync/demos/Utils.kt
new file mode 100644
index 00000000..634ee005
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/androidMain/kotlin/com/powersync/demos/Utils.kt
@@ -0,0 +1,26 @@
+@file:JvmName("Utils")
+
+package com.powersync.demos
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+internal actual val MARGIN_SCROLLBAR: Dp = 0.dp
+
+internal actual interface ScrollbarAdapter
+
+@Composable
+internal actual fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter =
+ object : ScrollbarAdapter {}
+
+@Composable
+internal actual fun VerticalScrollbar(
+ modifier: Modifier,
+ adapter: ScrollbarAdapter
+) {
+ // no-op
+}
+
diff --git a/demos/supabase-todolist/shared/src/androidMain/kotlin/com/powersync/demos/main.android.kt b/demos/supabase-todolist/shared/src/androidMain/kotlin/com/powersync/demos/main.android.kt
new file mode 100644
index 00000000..f44d8f25
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/androidMain/kotlin/com/powersync/demos/main.android.kt
@@ -0,0 +1,9 @@
+package com.powersync.demos
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.powersync.DatabaseDriverFactory
+
+@Composable
+fun MainView(factory: DatabaseDriverFactory) = RootContent(factory, Modifier.fillMaxSize())
\ No newline at end of file
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/demos/supabase-todolist/shared/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 00000000..1f6bb290
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/drawable/ic_launcher_background.xml b/demos/supabase-todolist/shared/src/androidMain/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..0d025f9b
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/androidMain/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..6b78462d
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..6b78462d
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..898f3ed5
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..dffca360
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..64ba76f7
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..dae5e082
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..e5ed4659
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..14ed0af3
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..b0907cac
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..d8ae0315
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..2c18de9e
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..beed3cdd
Binary files /dev/null and b/demos/supabase-todolist/shared/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/demos/supabase-todolist/shared/src/androidMain/res/values/strings.xml b/demos/supabase-todolist/shared/src/androidMain/res/values/strings.xml
new file mode 100644
index 00000000..5dee5cbd
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/androidMain/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ TodoApp Lite
+
diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/EditDialog.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/EditDialog.kt
new file mode 100644
index 00000000..8935b5c1
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/EditDialog.kt
@@ -0,0 +1,91 @@
+package com.powersync.demos
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.material.Button
+import androidx.compose.material.Card
+import androidx.compose.material.Checkbox
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.ProvideTextStyle
+import androidx.compose.material.Text
+import androidx.compose.material.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+
+@Composable
+internal fun EditDialog(
+ item: TodoItem,
+ onCloseClicked: () -> Unit,
+ onTextChanged: (String) -> Unit,
+ onDoneChanged: (Boolean) -> Unit,
+) {
+ EditDialog(
+ onCloseRequest = onCloseClicked,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ TextField(
+ value = item.description,
+ modifier = Modifier.weight(1F).fillMaxWidth().sizeIn(minHeight = 192.dp),
+ label = { Text("Todo text") },
+ onValueChange = onTextChanged,
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row {
+ Text(text = "Completed", Modifier.padding(15.dp))
+ Checkbox(
+ checked = item.completed,
+ onCheckedChange = onDoneChanged,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun EditDialog(
+ onCloseRequest: () -> Unit,
+ content: @Composable () -> Unit
+) {
+ Dialog(
+ onDismissRequest = onCloseRequest,
+ ) {
+ Card(elevation = 8.dp) {
+ Column(
+ modifier = Modifier
+ .padding(8.dp)
+ .height(IntrinsicSize.Min)
+ ) {
+ ProvideTextStyle(MaterialTheme.typography.subtitle1) {
+ Text(text = "Edit todo")
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Box(modifier = Modifier.weight(1F)) {
+ content()
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = onCloseRequest,
+ modifier = Modifier.align(Alignment.End)
+ ) {
+ Text(text = "Done")
+ }
+ }
+ }
+ }
+}
diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/MainContent.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/MainContent.kt
new file mode 100644
index 00000000..9f743855
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/MainContent.kt
@@ -0,0 +1,158 @@
+package com.powersync.demos
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.Checkbox
+import androidx.compose.material.Divider
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Text
+import androidx.compose.material.TopAppBar
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+
+@Composable
+internal fun MainContent(
+ modifier: Modifier = Modifier,
+ items: List,
+ inputText: String,
+ onItemClicked: (item: TodoItem) -> Unit,
+ onItemDoneChanged: (item: TodoItem, isDone: Boolean) -> Unit,
+ onItemDeleteClicked: (item: TodoItem) -> Unit,
+ onAddItemClicked: () -> Unit,
+ onInputTextChanged: (value: String) -> Unit,
+) {
+ Column(modifier) {
+ TopAppBar(title = { Text(text = "Todo List") })
+
+ Input(
+ text = inputText,
+ onAddClicked = onAddItemClicked,
+ onTextChanged = onInputTextChanged
+ )
+
+ Box(Modifier.weight(1F)) {
+ ListContent(
+ items = items,
+ onItemClicked = onItemClicked,
+ onItemDoneChanged = onItemDoneChanged,
+ onItemDeleteClicked = onItemDeleteClicked
+ )
+ }
+ }
+}
+
+@Composable
+private fun ListContent(
+ items: List,
+ onItemClicked: (id: TodoItem) -> Unit,
+ onItemDoneChanged: (item: TodoItem, isDone: Boolean) -> Unit,
+ onItemDeleteClicked: (item: TodoItem) -> Unit,
+) {
+ Box {
+ val listState = rememberLazyListState()
+
+ LazyColumn(state = listState) {
+ items(items) { item ->
+ Item(
+ item = item,
+ onClicked = { onItemClicked(item) },
+ onDoneChanged = { onItemDoneChanged(item, it) },
+ onDeleteClicked = { onItemDeleteClicked(item) }
+ )
+
+ Divider()
+ }
+ }
+
+ VerticalScrollbar(
+ modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
+ adapter = rememberScrollbarAdapter(scrollState = listState)
+ )
+ }
+}
+
+@Composable
+private fun Item(
+ item: TodoItem,
+ onClicked: () -> Unit,
+ onDoneChanged: (Boolean) -> Unit,
+ onDeleteClicked: () -> Unit
+) {
+ Row(modifier = Modifier.clickable(onClick = onClicked)) {
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Checkbox(
+ checked = item.completed,
+ modifier = Modifier.align(Alignment.CenterVertically),
+ onCheckedChange = onDoneChanged,
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = AnnotatedString(item.description),
+ modifier = Modifier.weight(1F).align(Alignment.CenterVertically),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ IconButton(onClick = onDeleteClicked) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null
+ )
+ }
+
+ Spacer(modifier = Modifier.width(MARGIN_SCROLLBAR))
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun Input(
+ text: String,
+ onTextChanged: (String) -> Unit,
+ onAddClicked: () -> Unit
+) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
+ OutlinedTextField(
+ value = text,
+ modifier = Modifier
+ .weight(weight = 1F)
+ .onKeyUp(key = Key.Enter, action = onAddClicked),
+ onValueChange = onTextChanged,
+ label = { Text(text = "Add a todo") }
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ IconButton(onClick = onAddClicked) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null
+ )
+ }
+ }
+}
diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/RootContent.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/RootContent.kt
new file mode 100644
index 00000000..04a75e10
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/RootContent.kt
@@ -0,0 +1,38 @@
+package com.powersync.demos
+
+import androidx.compose.foundation.background
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import com.powersync.DatabaseDriverFactory
+
+@Composable
+fun RootContent(factory: DatabaseDriverFactory, modifier: Modifier = Modifier) {
+ val model = remember { RootStore(factory) }
+ val state = model.state
+ val items by model.watchItems().collectAsState(initial = emptyList())
+
+ MainContent(
+ modifier = modifier.background(MaterialTheme.colors.background),
+ items = items,
+ inputText = state.inputText,
+ onItemClicked = model::onItemClicked,
+ onItemDoneChanged = model::onItemDoneChanged,
+ onItemDeleteClicked = model::onItemDeleteClicked,
+ onAddItemClicked = model::onAddItemClicked,
+ onInputTextChanged = model::onInputTextChanged,
+ )
+
+
+ state.editingItem?.also {
+ EditDialog(
+ item = it,
+ onCloseClicked = model::onEditorCloseClicked,
+ onTextChanged = model::onEditorTextChanged,
+ onDoneChanged = model::onEditorDoneChanged,
+ )
+ }
+}
diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/RootStore.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/RootStore.kt
new file mode 100644
index 00000000..fd842a80
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/RootStore.kt
@@ -0,0 +1,123 @@
+package com.powersync.demos
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.powersync.DatabaseDriverFactory
+import com.powersync.PowerSyncBuilder
+import com.powersync.connector.supabase.SupabaseConnector
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.runBlocking
+
+internal class RootStore(factory: DatabaseDriverFactory) {
+
+ private val db = PowerSyncBuilder.from(factory, schema).build()
+ private val connector = SupabaseConnector(
+ powerSyncEndpoint = Config.POWERSYNC_URL,
+ supabaseUrl = Config.SUPABASE_URL,
+ supabaseKey = Config.SUPABASE_ANON_KEY
+ )
+
+ var state: RootState by mutableStateOf(initialState())
+ private set
+
+ init {
+ runBlocking {
+ try {
+ connector.login(Config.SUPABASE_USER_EMAIL, Config.SUPABASE_USER_PASSWORD)
+ } catch (e: Exception) {
+ println("Could not connect to Supabase, have you configured an auth user and set `SUPABASE_USER_EMAIL` and `SUPABASE_USER_PASSWORD`?\n Error: $e")
+ }
+ db.connect(connector)
+ }
+ }
+
+ fun watchItems(): Flow> {
+ return db.watch("SELECT * FROM todos") { cursor ->
+ TodoItem(
+ id = cursor.getString(0)!!,
+ description = cursor.getString(1)!!,
+ completed = cursor.getLong(2) == 1L
+ )
+ }
+ }
+
+ fun onItemClicked(item: TodoItem) {
+ setState { copy(editingItem = item) }
+ }
+
+ fun onItemDoneChanged(item: TodoItem, isDone: Boolean) {
+ updateItem(item = item) { it.copy(completed = isDone) }
+ }
+
+ fun onItemDeleteClicked(item: TodoItem) {
+ runBlocking {
+ db.writeTransaction {
+ db.execute("DELETE FROM todos WHERE id = ?", listOf(item.id))
+ }
+ }
+ }
+
+ fun onAddItemClicked() {
+ if (state.inputText.isBlank()) return
+
+ runBlocking {
+ db.writeTransaction {
+ db.execute(
+ "INSERT INTO todos (id, description, completed) VALUES (uuid(), ?, ?)",
+ listOf(state.inputText, 0L)
+ )
+ }
+ setState {
+ copy(inputText = "")
+ }
+ }
+ }
+
+ fun onInputTextChanged(text: String) {
+ setState { copy(inputText = text) }
+ }
+
+ fun onEditorCloseClicked() {
+ updateItem(item = requireNotNull(state.editingItem)) { it.copy() }
+ setState { copy(editingItem = null) }
+ }
+
+ fun onEditorTextChanged(text: String) {
+ updateEditingItem(item = requireNotNull(state.editingItem)) { it.copy(description = text) }
+ }
+
+ fun onEditorDoneChanged(isDone: Boolean) {
+ updateEditingItem(item = requireNotNull(state.editingItem)) { it.copy(completed = isDone) }
+ }
+
+ private fun updateEditingItem(item: TodoItem, transformer: (item: TodoItem) -> TodoItem) {
+ setState { copy(editingItem = transformer(item)) }
+ }
+
+ private fun updateItem(item: TodoItem, transformer: (item: TodoItem) -> TodoItem) {
+ runBlocking {
+ val updatedItem = transformer(item)
+ db.writeTransaction {
+ db.execute(
+ "UPDATE todos SET description = ?, completed = ? WHERE id = ?",
+ listOf(updatedItem.description, updatedItem.completed, item.id)
+ )
+ }
+ }
+ }
+
+
+ private fun initialState(): RootState =
+ RootState()
+
+ private inline fun setState(update: RootState.() -> RootState) {
+ state = state.update()
+ }
+
+ data class RootState(
+ val inputText: String = "",
+ val editingItem: TodoItem? = null
+ )
+
+}
diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Schema.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Schema.kt
new file mode 100644
index 00000000..b4e27501
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Schema.kt
@@ -0,0 +1,23 @@
+package com.powersync.demos
+
+import com.powersync.db.schema.Column
+import com.powersync.db.schema.Schema
+import com.powersync.db.schema.Table
+
+val schema: Schema = Schema(
+ listOf(
+ Table(
+ "todos",
+ listOf(
+ Column.text("description"),
+ Column.integer("completed") // 0 or 1 to represent false or true
+ )
+ )
+ )
+)
+
+data class TodoItem(
+ val id: String,
+ val description: String,
+ val completed: Boolean = false
+)
diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Utils.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Utils.kt
new file mode 100644
index 00000000..68c75235
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Utils.kt
@@ -0,0 +1,34 @@
+package com.powersync.demos
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.unit.Dp
+
+internal expect val MARGIN_SCROLLBAR: Dp
+
+internal expect interface ScrollbarAdapter
+
+@Composable
+internal expect fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter
+
+@Composable
+internal expect fun VerticalScrollbar(
+ modifier: Modifier,
+ adapter: ScrollbarAdapter
+)
+
+internal fun Modifier.onKeyUp(key: Key, action: () -> Unit): Modifier =
+ onKeyEvent { event ->
+ if ((event.type == KeyEventType.KeyUp) && (event.key == key)) {
+ action()
+ true
+ } else {
+ false
+ }
+ }
diff --git a/demos/supabase-todolist/shared/src/desktopMain/kotlin/com/powersync/demos/Utils.kt b/demos/supabase-todolist/shared/src/desktopMain/kotlin/com/powersync/demos/Utils.kt
new file mode 100644
index 00000000..a52659be
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/desktopMain/kotlin/com/powersync/demos/Utils.kt
@@ -0,0 +1,30 @@
+@file:JvmName("Utils")
+
+package com.powersync.demos
+
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+internal actual val MARGIN_SCROLLBAR: Dp = 8.dp
+
+@Suppress("ACTUAL_WITHOUT_EXPECT") // Workaround https://youtrack.jetbrains.com/issue/KT-37316
+internal actual typealias ScrollbarAdapter = androidx.compose.foundation.v2.ScrollbarAdapter
+
+@Composable
+internal actual fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter =
+ androidx.compose.foundation.rememberScrollbarAdapter(scrollState)
+
+@Composable
+internal actual fun VerticalScrollbar(
+ modifier: Modifier,
+ adapter: ScrollbarAdapter
+) {
+ androidx.compose.foundation.VerticalScrollbar(
+ modifier = modifier,
+ adapter = adapter
+ )
+}
diff --git a/demos/supabase-todolist/shared/src/desktopMain/kotlin/main.desktop.kt b/demos/supabase-todolist/shared/src/desktopMain/kotlin/main.desktop.kt
new file mode 100644
index 00000000..ea4a82d8
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/desktopMain/kotlin/main.desktop.kt
@@ -0,0 +1,6 @@
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.powersync.demos.RootContent
+
+@Composable fun MainView() = RootContent(Modifier.fillMaxSize())
\ No newline at end of file
diff --git a/demos/supabase-todolist/shared/src/iosMain/kotlin/com/powersync/demos/Utils.kt b/demos/supabase-todolist/shared/src/iosMain/kotlin/com/powersync/demos/Utils.kt
new file mode 100644
index 00000000..15552935
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/iosMain/kotlin/com/powersync/demos/Utils.kt
@@ -0,0 +1,23 @@
+package com.powersync.demos
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+internal actual val MARGIN_SCROLLBAR: Dp = 0.dp
+
+internal actual interface ScrollbarAdapter
+
+@Composable
+internal actual fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter =
+ object : ScrollbarAdapter {}
+
+@Composable
+internal actual fun VerticalScrollbar(
+ modifier: Modifier,
+ adapter: ScrollbarAdapter
+) {
+ // no-op
+}
diff --git a/demos/supabase-todolist/shared/src/iosMain/kotlin/com/powersync/demos/main.ios.kt b/demos/supabase-todolist/shared/src/iosMain/kotlin/com/powersync/demos/main.ios.kt
new file mode 100644
index 00000000..05ea9b9e
--- /dev/null
+++ b/demos/supabase-todolist/shared/src/iosMain/kotlin/com/powersync/demos/main.ios.kt
@@ -0,0 +1,11 @@
+package com.powersync.demos
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.window.ComposeUIViewController
+import com.powersync.DatabaseDriverFactory
+import platform.UIKit.UIViewController
+
+fun MainViewController(): UIViewController = ComposeUIViewController {
+ RootContent(factory = DatabaseDriverFactory(), modifier = Modifier.fillMaxSize())
+}
\ No newline at end of file
diff --git a/dialect/build.gradle b/dialect/build.gradle
new file mode 100644
index 00000000..d28067e1
--- /dev/null
+++ b/dialect/build.gradle
@@ -0,0 +1,19 @@
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.grammarKitComposer)
+}
+
+grammarKit {
+ intellijRelease.set(libs.versions.idea)
+}
+
+dependencies {
+ api(libs.sqldelight.dialect.sqlite335)
+ api(libs.sqldelight.dialect.sqlite338)
+
+ compileOnly(libs.sqldelight.compilerEnv)
+}
+
+kotlin {
+ jvmToolchain(17)
+}
\ No newline at end of file
diff --git a/dialect/src/main/kotlin/com/powersync/sqlite/PowerSyncDialect.kt b/dialect/src/main/kotlin/com/powersync/sqlite/PowerSyncDialect.kt
new file mode 100644
index 00000000..a97e3639
--- /dev/null
+++ b/dialect/src/main/kotlin/com/powersync/sqlite/PowerSyncDialect.kt
@@ -0,0 +1,25 @@
+package com.powersync.sqlite
+
+import app.cash.sqldelight.dialect.api.IntermediateType
+import app.cash.sqldelight.dialect.api.PrimitiveType
+import app.cash.sqldelight.dialect.api.SqlDelightDialect
+import app.cash.sqldelight.dialect.api.TypeResolver
+import app.cash.sqldelight.dialects.sqlite_3_35.SqliteTypeResolver
+import app.cash.sqldelight.dialects.sqlite_3_38.SqliteDialect as Sqlite338Dialect
+import com.alecstrong.sql.psi.core.psi.SqlFunctionExpr
+
+class PowerSyncDialect : SqlDelightDialect by Sqlite338Dialect() {
+ override fun typeResolver(parentResolver: TypeResolver) = PowerSyncTypeResolver(parentResolver)
+}
+
+class PowerSyncTypeResolver(private val parentResolver: TypeResolver) :
+ TypeResolver by SqliteTypeResolver(parentResolver) {
+ override fun functionType(functionExpr: SqlFunctionExpr): IntermediateType? {
+ when (functionExpr.functionName.text) {
+ "sqlite_version", "powersync_rs_version", "powersync_replace_schema" -> return IntermediateType(
+ PrimitiveType.TEXT
+ )
+ }
+ return parentResolver.functionType(functionExpr)
+ }
+}
diff --git a/dialect/src/main/resources/META-INF/services/app.cash.sqldelight.dialect.api.SqlDelightDialect b/dialect/src/main/resources/META-INF/services/app.cash.sqldelight.dialect.api.SqlDelightDialect
new file mode 100644
index 00000000..2d4118ed
--- /dev/null
+++ b/dialect/src/main/resources/META-INF/services/app.cash.sqldelight.dialect.api.SqlDelightDialect
@@ -0,0 +1 @@
+com.powersync.sqlite.PowerSyncDialect
diff --git a/docs/assets/android-studio-run-configuration.png b/docs/assets/android-studio-run-configuration.png
new file mode 100644
index 00000000..3955c174
Binary files /dev/null and b/docs/assets/android-studio-run-configuration.png differ
diff --git a/gradle.properties b/gradle.properties
index 3c5031eb..d5a3d311 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,23 +1,33 @@
-# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
-# Specifies the JVM arguments used for the daemon process.
-# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
-# AndroidX package structure to make it clearer which packages are bundled with the
-# Android operating system, and which are packaged with your app's APK
-# https://developer.android.com/topic/libraries/support-library/androidx-rn
-android.useAndroidX=true
-# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
-# Enables namespacing of each library's R class so that its R class includes only the
-# resources declared in the library itself and none from the library's dependencies,
-# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+# Gradle
+org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M"
+# Compose
+org.jetbrains.compose.experimental.uikit.enabled=true
+# Android
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+# MPP
+kotlin.mpp.androidSourceSetLayoutVersion=2
+kotlin.mpp.enableCInteropCommonization=true
+kotlin.native.cacheKind.iosSimulatorArm64=none
+compose.kotlin.native.manageCacheKind=false
+# Development
+development=true
+# Release
+RELEASE_SIGNING_ENABLED=true
+# Library config
+GROUP=com.powersync
+LIBRARY_VERSION=0.0.1-ALPHA2
+GITHUB_REPO=https://github.com/powersync-ja/powersync-kotlin.git
+# POM
+POM_URL=https://github.com/powersync-ja/powersync-kotlin/
+POM_SCM_URL=https://github.com/powersync-ja/powersync-kotlin
+POM_SCM_CONNECTION=scm:git:https://github.com/powersync-ja/powersync-kotlin.git
+POM_SCM_DEV_CONNECTION=scm:git:git@github.com:powersync-ja/powersync-kotlin.git
+POM_LICENCE_NAME=MIT
+POM_LICENCE_URL=http://opensource.org/licenses/MIT
+POM_LICENCE_DIST=repo
+POM_DEVELOPER_ID=powersync
+POM_DEVELOPER_NAME=PowerSync SDK Team
+POM_DEVELOPER_URL=https://powersync.com/
+POM_DEVELOPER_EMAIL=support@journeyapps.com
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 00000000..f47022fa
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,110 @@
+[versions]
+## SDK Versions
+android-minSdk = "24"
+android-targetSdk = "34"
+android-compileSdk = "34"
+java = "17"
+idea = "222.4459.24" # Flamingo | 2022.2.1 (see https://plugins.jetbrains.com/docs/intellij/android-studio-releases-list.html)
+
+# Dependencies
+kotlin = "1.9.22"
+coroutines = "1.7.3"
+kotlinx-datetime = "0.5.0"
+kotlinx-io = "0.3.0"
+ktor = "2.3.7"
+uuid = "0.8.2"
+powersync-core = "0.1.6"
+sqlite-android = "3.45.0"
+
+sqlDelight = "2.0.1"
+stately = "2.0.6"
+supabase = "2.0.4"
+junit = "4.13.2"
+
+compose = "1.5.12"
+compose-preview = "1.6.0"
+compose-compiler = "1.5.8"
+
+# plugins
+android-gradle-plugin = "8.2.2"
+kmmBridge = "0.5.1"
+skie = "0.6.1"
+maven-publish = "0.27.0"
+download-plugin = "5.5.0"
+grammerKit = "0.1.12"
+
+# Sample - Android
+androidx-core = "1.12.0"
+androidx-activity-compose = "1.8.2"
+
+androidx-appcompat = "1.6.1"
+androidx-constraintlayout = "2.1.4"
+androidx-espresso-core = "3.5.1"
+androidx-material = "1.11.0"
+androidx-test-junit = "1.1.5"
+org-jetbrains-kotlin-jvm = "1.9.22"
+
+[libraries]
+compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose-compiler" }
+powersync-sqlite-core = { module = "co.powersync:powersync-sqlite-core", version.ref = "powersync-core" }
+mavenPublishPlugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven-publish" }
+
+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" }
+kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
+kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
+
+kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
+kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
+
+uuid = { module = "com.benasher44:uuid", version.ref = "uuid" }
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-ios = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", 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-android = { module = "app.cash.sqldelight:android-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" }
+sqldelight-dialect-sqlite335 = { module = "app.cash.sqldelight:sqlite-3-35-dialect", version.ref = "sqlDelight" }
+sqldelight-compilerEnv = { module = "app.cash.sqldelight:compiler-env", version.ref = "sqlDelight" }
+
+stately-concurrency = { module = "co.touchlab:stately-concurrency", version.ref = "stately" }
+supabase-client = { module = "io.github.jan-tennert.supabase:postgrest-kt", version.ref = "supabase" }
+supabase-gotrue = { module = "io.github.jan-tennert.supabase:gotrue-kt", version.ref = "supabase" }
+
+# Sample - Android
+androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
+androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" }
+compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose-preview" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "android-gradle-plugin" }
+androidLibrary = { id = "com.android.library", version.ref = "android-gradle-plugin" }
+jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose" }
+cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+skie = { id = "co.touchlab.skie", version.ref = "skie" }
+kmmbridge = { id = "co.touchlab.kmmbridge", version.ref = "kmmBridge" }
+sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }
+grammarKitComposer = { id = "com.alecstrong.grammar.kit.composer", version.ref = "grammerKit" }
+mavenPublishPlugin = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" }
+downloadPlugin = { id = "de.undercouch.download", version.ref = "download-plugin" }
+
+[bundles]
+sqldelight = [
+ "sqldelight-runtime",
+ "sqldelight-coroutines"
+]
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index e708b1c0..033e24c4 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 47b83c67..1af9e093 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,7 @@
-#Mon Oct 09 12:38:16 SAST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 4f906e0c..fcb6fca1 100755
--- a/gradlew
+++ b/gradlew
@@ -1,7 +1,7 @@
-#!/usr/bin/env sh
+#!/bin/sh
#
-# Copyright 2015 the original author or authors.
+# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,67 +17,98 @@
#
##############################################################################
-##
-## Gradle start up script for UN*X
-##
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
##############################################################################
# Attempt to set APP_HOME
+
# Resolve links: $0 may be a link
-PRG="$0"
-# Need this for relative symlinks.
-while [ -h "$PRG" ] ; do
- ls=`ls -ld "$PRG"`
- link=`expr "$ls" : '.*-> \(.*\)$'`
- if expr "$link" : '/.*' > /dev/null; then
- PRG="$link"
- else
- PRG=`dirname "$PRG"`"/$link"
- fi
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
done
-SAVED="`pwd`"
-cd "`dirname \"$PRG\"`/" >/dev/null
-APP_HOME="`pwd -P`"
-cd "$SAVED" >/dev/null
-APP_NAME="Gradle"
-APP_BASE_NAME=`basename "$0"`
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD="maximum"
+MAX_FD=maximum
warn () {
echo "$*"
-}
+} >&2
die () {
echo
echo "$*"
echo
exit 1
-}
+} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
-case "`uname`" in
- CYGWIN* )
- cygwin=true
- ;;
- Darwin* )
- darwin=true
- ;;
- MINGW* )
- msys=true
- ;;
- NONSTOP* )
- nonstop=true
- ;;
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
@@ -87,9 +118,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
- JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACMD=$JAVA_HOME/jre/sh/java
else
- JAVACMD="$JAVA_HOME/bin/java"
+ JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -98,88 +129,120 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
- JAVACMD="java"
- which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
+ fi
fi
# Increase the maximum file descriptors if we can.
-if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
- MAX_FD_LIMIT=`ulimit -H -n`
- if [ $? -eq 0 ] ; then
- if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
- MAX_FD="$MAX_FD_LIMIT"
- fi
- ulimit -n $MAX_FD
- if [ $? -ne 0 ] ; then
- warn "Could not set maximum file descriptor limit: $MAX_FD"
- fi
- else
- warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
- fi
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
fi
-# For Darwin, add options to specify how the application appears in the dock
-if $darwin; then
- GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
-fi
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
-if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
- APP_HOME=`cygpath --path --mixed "$APP_HOME"`
- CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
-
- JAVACMD=`cygpath --unix "$JAVACMD"`
-
- # We build the pattern for arguments to be converted via cygpath
- ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
- SEP=""
- for dir in $ROOTDIRSRAW ; do
- ROOTDIRS="$ROOTDIRS$SEP$dir"
- SEP="|"
- done
- OURCYGPATTERN="(^($ROOTDIRS))"
- # Add a user-defined pattern to the cygpath arguments
- if [ "$GRADLE_CYGPATTERN" != "" ] ; then
- OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
- fi
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
# Now convert the arguments - kludge to limit ourselves to /bin/sh
- i=0
- for arg in "$@" ; do
- CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
- CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
-
- if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
- eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
- else
- eval `echo args$i`="\"$arg\""
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
fi
- i=`expr $i + 1`
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
done
- case $i in
- 0) set -- ;;
- 1) set -- "$args0" ;;
- 2) set -- "$args0" "$args1" ;;
- 3) set -- "$args0" "$args1" "$args2" ;;
- 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
- 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
- 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
- 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
- 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
- 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
- esac
fi
-# Escape application args
-save () {
- for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
- echo " "
-}
-APP_ARGS=`save "$@"`
-# Collect all arguments for the java command, following the shell quoting and substitution rules
-eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
index ac1b06f9..6689b85b 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
-@if "%DEBUG%" == "" @echo off
+@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@@ -25,7 +25,8 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto execute
+if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
+if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
diff --git a/plugins/settings.gradle.kts b/plugins/settings.gradle.kts
new file mode 100644
index 00000000..5b507f9a
--- /dev/null
+++ b/plugins/settings.gradle.kts
@@ -0,0 +1,25 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ google()
+ gradlePluginPortal()
+ mavenCentral()
+ }
+
+ versionCatalogs {
+ create("libs") {
+ from(files("../gradle/libs.versions.toml"))
+ }
+ }
+}
+
+rootProject.name = "plugins"
+
+include(":sonatype")
\ No newline at end of file
diff --git a/plugins/sonatype/build.gradle.kts b/plugins/sonatype/build.gradle.kts
new file mode 100644
index 00000000..fb390d7a
--- /dev/null
+++ b/plugins/sonatype/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ id("java-gradle-plugin")
+ alias(libs.plugins.kotlin.jvm)
+}
+
+gradlePlugin {
+ // Define the plugin
+ val sonatypeCentralUpload by plugins.creating {
+ id = "com.powersync.plugins.sonatype"
+ implementationClass = "com.powersync.plugins.sonatype.SonatypeCentralUploadPlugin"
+ }
+}
+
+
+dependencies {
+ implementation(libs.mavenPublishPlugin)
+}
\ No newline at end of file
diff --git a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/ProjectExtensions.kt b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/ProjectExtensions.kt
new file mode 100644
index 00000000..5a4839bc
--- /dev/null
+++ b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/ProjectExtensions.kt
@@ -0,0 +1,54 @@
+package com.powersync.plugins.sonatype
+
+import org.gradle.api.Project
+import org.gradle.api.publish.PublishingExtension
+import java.net.URI
+
+inline val Project.gradlePublishing: PublishingExtension
+ get() = extensions.getByType(PublishingExtension::class.java)
+
+fun Project.findOptionalProperty(propertyName: String) = findProperty(propertyName)?.toString()
+
+
+/** Sets up repository for publishing to Github Packages to GITHUB_REPO property
+ * username and password (a personal Github access token) should be specified as
+ * `GITHUB_PUBLISH_USER` and `GITHUB_PUBLISH_TOKEN` gradle properties
+ */
+@Suppress("unused")
+fun Project.setupGithubRepository() {
+ gradlePublishing.apply {
+ val githubRepo = githubRepoOrNull ?: return
+
+ val githubPublishToken =
+ githubPublishTokenOrNull ?: return
+ val githubPublishUser = project.githubPublishUser ?: "cirunner"
+
+ repositories.maven {
+ it.name = "githubPackages"
+ it.url = URI.create("https://maven.pkg.github.com/$githubRepo")
+ it.credentials { cred ->
+ cred.username = githubPublishUser
+ cred.password = githubPublishToken
+ }
+ }
+ }
+}
+
+internal val Project.githubPublishUser: String?
+ get() = project.findOptionalProperty("GITHUB_PUBLISH_USER")
+
+internal val Project.githubPublishTokenOrNull: String?
+ get() = project.findOptionalProperty("GITHUB_PUBLISH_TOKEN")
+
+internal val Project.githubRepoOrNull: String?
+ get() {
+ val repo = findOptionalProperty("GITHUB_REPO") ?: return null
+ val repoWithoutGitSuffix = repo.removeSuffix(".git")
+ val regex = Regex("((.*)[/:])?(?[^:/]+)/(?[^/]+)")
+ val matchResult = regex.matchEntire(repoWithoutGitSuffix)
+ if (matchResult != null) {
+ return (matchResult.groups["owner"]!!.value + "/" + matchResult.groups["repo"]!!.value)
+ } else {
+ throw IllegalArgumentException("Incorrect Github repository path, should be \"Owner/Repo\"")
+ }
+ }
\ No newline at end of file
diff --git a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/PublishToCentralPortalTask.kt b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/PublishToCentralPortalTask.kt
new file mode 100644
index 00000000..bfaee368
--- /dev/null
+++ b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/PublishToCentralPortalTask.kt
@@ -0,0 +1,106 @@
+package com.powersync.plugins.sonatype
+
+import com.powersync.plugins.sonatype.SonatypeCentralExtension.Companion.COMPONENT_BUNDLE_TASK_NAME
+import org.gradle.api.DefaultTask
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.TaskAction
+import org.gradle.api.tasks.bundling.Zip
+import java.io.File
+import java.io.FileInputStream
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URI
+import java.net.URISyntaxException
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+abstract class PublishToCentralPortalTask : DefaultTask() {
+ @get:Input
+ @get:Optional
+ abstract val username: Property
+
+ @get:Input
+ @get:Optional
+ abstract val password: Property
+
+ private fun outputFile(): File {
+ val archive = project.tasks.getByName(COMPONENT_BUNDLE_TASK_NAME) as Zip
+ return archive.archiveFile.get().asFile
+ }
+
+ companion object {
+ const val UPLOAD_ENDPOINT =
+ "https://central.sonatype.com/api/v1/publisher/upload?publishingType=USER_MANAGED"
+ const val SUCCESS_STATUS_CODE = 201
+ }
+
+ @TaskAction
+ @Throws(IOException::class, URISyntaxException::class)
+ fun sendRequest() {
+
+ val extension = project.extensions.getByType(SonatypeCentralExtension::class.java)
+
+ val username = this.username.getOrNull() ?: extension.username.getOrNull() ?: project.findOptionalProperty(SonatypeCentralExtension.SONATYPE_USERNAME_KEY)
+ ?: throw IOException(
+ "Missing PublishToCentralPortal's `username` and `${SonatypeCentralExtension.SONATYPE_USERNAME_KEY}` value and `${SonatypeCentralExtension.SONATYPE_USERNAME_KEY}` property"
+ )
+
+ val password = this.password.getOrNull() ?: extension.password.getOrNull() ?: project.findOptionalProperty(SonatypeCentralExtension.SONATYPE_PASSWORD_KEY)
+ ?: throw IOException(
+ "Missing PublishToCentralPortal's `password` and `${SonatypeCentralExtension.SONATYPE_PASSWORD_KEY}` value and `${SonatypeCentralExtension.SONATYPE_PASSWORD_KEY}` property"
+ )
+
+ val outputFile = this.outputFile();
+
+ val name = URLEncoder.encode(
+ (project.group
+ .toString() + ":" + project.name
+ + ":" + project.version),
+ StandardCharsets.UTF_8
+ )
+ val userPass = "$username:$password"
+ val token = Base64.getEncoder().encodeToString(userPass.toByteArray())
+ val conn = URI(UPLOAD_ENDPOINT + "&name=" + name)
+ .toURL()
+ .openConnection() as HttpURLConnection
+ conn.doOutput = true
+ conn.requestMethod = "POST"
+ conn.setRequestProperty("Authorization", "UserToken $token")
+ val boundary = "---------------------------" + java.lang.Long.toHexString(
+ System.currentTimeMillis().inv()
+ )
+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
+ conn.outputStream.use { out ->
+ out.write(("--" + boundary).toByteArray())
+ out.write("\r\nContent-Disposition: form-data; name=\"bundle\"; filename=\"bundle.zip\"".toByteArray())
+ out.write("\r\nContent-Type: application/octet-stream".toByteArray())
+ out.write("\r\n\r\n".toByteArray())
+ FileInputStream(outputFile).use { inputStream ->
+ val buffer: ByteArray = ByteArray(1024)
+ var available: Long = outputFile.length()
+ while (available > 0) {
+ val read: Int = inputStream.read(
+ buffer,
+ 0,
+ Math.min(buffer.size.toLong(), available).toInt()
+ )
+ out.write(buffer, 0, read)
+ available -= read.toLong()
+ }
+ }
+ out.write(("\r\n--" + boundary + "--\r\n").toByteArray())
+ out.flush()
+ }
+ val status = conn.responseCode
+ when (status) {
+ SUCCESS_STATUS_CODE -> {}
+ else -> throw IOException(
+ "Error " + status + ": " + String(conn.errorStream.readAllBytes())
+ )
+ }
+ }
+
+}
+
diff --git a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralExtension.kt b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralExtension.kt
new file mode 100644
index 00000000..954ef43e
--- /dev/null
+++ b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralExtension.kt
@@ -0,0 +1,67 @@
+package com.powersync.plugins.sonatype
+
+import org.gradle.api.Action
+import org.gradle.api.Project
+import org.gradle.api.artifacts.repositories.MavenArtifactRepository
+import org.gradle.api.artifacts.repositories.PasswordCredentials
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.bundling.Zip
+
+abstract class SonatypeCentralExtension(
+ private val project: Project
+) {
+ @get:Optional
+ val username: Property = project.objects.property(String::class.java)
+
+ @get:Optional
+ val password: Property = project.objects.property(String::class.java)
+
+ companion object {
+ const val NAME = "sonatypePublishing"
+ const val GROUP = "publishing"
+ const val REPO_DIR = "sonatypeLocal"
+ const val BUNDLE_DIR = "sonatypeBundles"
+
+ const val PUBLISH_TASK_NAME = "publishAllPublicationsToSonatypeRepository"
+ const val PUBLISH_LOCAL_TASK_NAME = "publishAllPublicationsToSonatypeLocalRepository"
+ const val COMPONENT_BUNDLE_TASK_NAME = "generateSonatypeComponentBundle"
+
+ const val SONATYPE_USERNAME_KEY = "centralPortal.username"
+ const val SONATYPE_PASSWORD_KEY = "centralPortal.password"
+ }
+
+ internal fun apply() {
+ val log = project.logger
+ log.info("Setting up the `:${PUBLISH_LOCAL_TASK_NAME}` task")
+ project.gradlePublishing.repositories.maven { repo ->
+ repo.name = REPO_DIR
+ repo.url = project.uri(project.layout.buildDirectory.dir(REPO_DIR))
+ }
+
+ log.info("Setting up the `:${COMPONENT_BUNDLE_TASK_NAME}` task")
+ val sonatypeCentralUploadDir =
+ project.file(project.layout.buildDirectory.dir(REPO_DIR))
+
+ project.tasks.create(COMPONENT_BUNDLE_TASK_NAME, Zip::class.java) {
+ it.group = GROUP
+ it.description = "Creates a bundle for Sonatype Central Portal publishing."
+ it.archiveClassifier.set("bundle")
+ it.dependsOn(project.tasks.named(PUBLISH_LOCAL_TASK_NAME))
+ it.from(sonatypeCentralUploadDir) { cp ->
+ cp.exclude { fileTreeElement ->
+ fileTreeElement.relativePath.lastName.startsWith("maven-metadata")
+ }
+ }
+ it.destinationDirectory.set(project.layout.buildDirectory.dir(BUNDLE_DIR))
+ }
+
+ log.info("Setting up the `:${PUBLISH_TASK_NAME}` task")
+ project.tasks.create(PUBLISH_TASK_NAME, PublishToCentralPortalTask::class.java) {
+ it.group = GROUP
+ it.description = "Publishes the bundle to Sonatype Central Portal"
+ it.dependsOn(project.tasks.named(COMPONENT_BUNDLE_TASK_NAME))
+ }
+ }
+}
+
diff --git a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralUploadPlugin.kt b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralUploadPlugin.kt
new file mode 100644
index 00000000..db1f7f0a
--- /dev/null
+++ b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralUploadPlugin.kt
@@ -0,0 +1,19 @@
+package com.powersync.plugins.sonatype
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import com.vanniktech.maven.publish.MavenPublishPlugin
+
+class SonatypeCentralUploadPlugin : Plugin {
+ override fun apply(project: Project) {
+ project.logger.info("Applying the `gradle-maven-publish` plugin")
+ project.plugins.apply(MavenPublishPlugin::class.java)
+
+ val extension = project.extensions.create(
+ SonatypeCentralExtension.NAME,
+ SonatypeCentralExtension::class.java,
+ project
+ )
+ extension.apply()
+ }
+}
diff --git a/powersyncswift/build.gradle.kts b/powersyncswift/build.gradle.kts
new file mode 100644
index 00000000..14830d3b
--- /dev/null
+++ b/powersyncswift/build.gradle.kts
@@ -0,0 +1,38 @@
+import co.touchlab.faktory.versionmanager.TimestampVersionManager
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.kmmbridge)
+ alias(libs.plugins.skie)
+ alias(libs.plugins.mavenPublishPlugin)
+}
+
+kotlin {
+ listOf(
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64()
+ ).forEach {
+ it.binaries.framework {
+ export(project(":core"))
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ api(project(":core"))
+ }
+ }
+}
+
+kmmbridge {
+ mavenPublishArtifacts()
+ spm()
+ versionManager.set(TimestampVersionManager)
+}
+
+if (System.getenv().containsKey("CI")) {
+ // Setup github publishing based on GitHub action variables
+ addGithubPackagesRepository()
+}
\ No newline at end of file
diff --git a/powersyncswift/gradle.properties b/powersyncswift/gradle.properties
new file mode 100644
index 00000000..d8f76cb9
--- /dev/null
+++ b/powersyncswift/gradle.properties
@@ -0,0 +1,2 @@
+POM_ARTIFACT_ID=powersyncswift
+POM_NAME=PowerSync Swift Framework
\ No newline at end of file
diff --git a/powersyncswift/powersync.podspec b/powersyncswift/powersync.podspec
new file mode 100644
index 00000000..e0161e92
--- /dev/null
+++ b/powersyncswift/powersync.podspec
@@ -0,0 +1,39 @@
+Pod::Spec.new do |spec|
+ spec.name = 'powersyncswift'
+ spec.version = '0.1'
+ spec.homepage = 'https://www.touchlab.co'
+ spec.source = { :http=> ''}
+ spec.authors = ''
+ spec.license = ''
+ spec.summary = 'PowerSyncSwift'
+ spec.vendored_frameworks = 'build/cocoapods/framework/powersyncswift.framework'
+
+ spec.ios.deployment_target = '13.5'
+
+
+ spec.pod_target_xcconfig = {
+ 'KOTLIN_PROJECT_PATH' => ':powersyncswift',
+ 'PRODUCT_MODULE_NAME' => 'powersyncswift',
+ }
+
+ spec.script_phases = [
+ {
+ :name => 'Build PowerSync Swift',
+ :execution_position => :before_compile,
+ :shell_path => '/bin/sh',
+ :script => <<-SCRIPT
+ if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
+ echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
+ exit 0
+ fi
+ set -ev
+ REPO_ROOT="$PODS_TARGET_SRCROOT"
+ "$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
+ -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
+ -Pkotlin.native.cocoapods.archs="$ARCHS" \
+ -Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
+ SCRIPT
+ }
+ ]
+ spec.libraries = 'c++', 'sqlite3'
+end
\ No newline at end of file
diff --git a/powersyncswift/src/iosMain/kotlin/com/powersync/SDK.kt b/powersyncswift/src/iosMain/kotlin/com/powersync/SDK.kt
new file mode 100644
index 00000000..e807cea2
--- /dev/null
+++ b/powersyncswift/src/iosMain/kotlin/com/powersync/SDK.kt
@@ -0,0 +1,3 @@
+package com.powersync
+
+fun sayHello() = "Hello from Kotlin!"
\ No newline at end of file
diff --git a/powersyncswift/src/main/AndroidManifest.xml b/powersyncswift/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..82c7c4d4
--- /dev/null
+++ b/powersyncswift/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index d08940f9..12b5bb01 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,17 +1,27 @@
pluginManagement {
repositories {
google()
- mavenCentral()
gradlePluginPortal()
+ mavenCentral()
}
+ includeBuild("plugins")
}
-//dependencyResolutionManagement {
-// repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
-// repositories {
-// google()
-// mavenCentral()
-// }
-//}
-rootProject.name = "Notes SQLite"
-include(":app")
+dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ maven("https://jitpack.io")
+ }
+}
+
+rootProject.name = "powersync"
+
+include(":core")
+include(":connectors:supabase")
+
+include(":dialect")
+include(":powersyncswift")
+
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")