From 7644f00c87072bab2622f90d1ee04f7e4e8f1637 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 24 Apr 2025 15:15:16 +0200 Subject: [PATCH 01/16] Add instruction class for core extension --- .../kotlin/com/powersync/sync/Instruction.kt | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt new file mode 100644 index 00000000..1c44440a --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt @@ -0,0 +1,73 @@ +package com.powersync.sync + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.serializer + +@Serializable(with = Instruction.Serializer::class) +internal sealed interface Instruction { + @Serializable + data class LogLine(val severity: String, val line: String): Instruction + + @Serializable + data class UpdateSyncStatus(val status: CoreSyncStatus): Instruction + + @Serializable + data class EstablishSyncStream(val request: JsonObject): Instruction + + object FlushSileSystem: Instruction + object CloseSyncStream: Instruction + object UnknownInstruction: Instruction + + class Serializer : KSerializer { + private val logLine = serializer() + private val updateSyncStatus = serializer() + private val establishSyncStream = serializer() + private val flushFileSystem = buildClassSerialDescriptor(FlushSileSystem::class.qualifiedName!!) {} + private val closeSyncStream = buildClassSerialDescriptor(CloseSyncStream::class.qualifiedName!!) {} + + override val descriptor = + buildClassSerialDescriptor(SyncLine::class.qualifiedName!!) { + element("LogLine", logLine.descriptor, isOptional = true) + element("UpdateSyncStatus", updateSyncStatus.descriptor, isOptional = true) + element("EstablishSyncStream", establishSyncStream.descriptor, isOptional = true) + element("FlushFileSystem", flushFileSystem, isOptional = true) + element("CloseSyncStream", closeSyncStream, isOptional = true) + } + + override fun deserialize(decoder: Decoder): Instruction = + decoder.decodeStructure(descriptor) { + val value = + when (val index = decodeElementIndex(descriptor)) { + 0 -> decodeSerializableElement(descriptor, 0, logLine) + 1 -> decodeSerializableElement(descriptor, 1, updateSyncStatus) + 2 -> decodeSerializableElement(descriptor, 2, establishSyncStream) + 3 -> FlushSileSystem + 4 -> CloseSyncStream + CompositeDecoder.UNKNOWN_NAME, CompositeDecoder.DECODE_DONE -> UnknownInstruction + else -> error("Unexpected index: $index") + } + + if (decodeElementIndex(descriptor) != CompositeDecoder.DECODE_DONE) { + // Sync lines are single-key objects, make sure there isn't another one. + UnknownInstruction + } else { + value + } + } + + override fun serialize(encoder: Encoder, value: Instruction) { + // We don't need this functionality, so... + throw UnsupportedOperationException("Serializing instructions") + } + } +} + +@Serializable +internal class CoreSyncStatus {} From 3afb537c0de6c4af3b972aa48219fa8f6c8e8c51 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 29 Apr 2025 11:49:04 +0200 Subject: [PATCH 02/16] Fix sync integration tests --- .../com/powersync/sync/SyncIntegrationTest.kt | 9 +- .../com/powersync/testutils/TestUtils.kt | 2 +- .../com/powersync/bucket/BucketStorage.kt | 17 +- .../com/powersync/bucket/BucketStorageImpl.kt | 249 +------------ .../kotlin/com/powersync/sync/Instruction.kt | 94 ++++- .../kotlin/com/powersync/sync/Progress.kt | 57 +-- .../kotlin/com/powersync/sync/SyncStatus.kt | 29 +- .../kotlin/com/powersync/sync/SyncStream.kt | 334 +++++------------- .../com/powersync/bucket/BucketStorageTest.kt | 21 -- .../com/powersync/sync/SyncStreamTest.kt | 131 ------- 10 files changed, 217 insertions(+), 726 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index fd91ff77..1ee617cb 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -440,8 +440,15 @@ class SyncIntegrationTest { // Trigger an upload (adding a keep-alive sync line because the execute could start before the database is fully // connected). + turbineScope { + val turbine = database.currentStatus.asFlow().testIn(this) + syncLines.send(SyncLine.KeepAlive(1234)) + turbine.waitFor { it.connected } + turbine.cancelAndIgnoreRemainingEvents() + } + database.execute("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", listOf("local", "local@example.org")) - syncLines.send(SyncLine.KeepAlive(1234)) + expectUserRows(1) uploadStarted.await() diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index 499327d0..05681c16 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -77,7 +77,7 @@ internal class ActiveDatabaseTest( val logger = Logger( TestConfig( - minSeverity = Severity.Debug, + minSeverity = Severity.Verbose, logWriterList = listOf(logWriter, generatePrintLogWriter()), ), ) diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt index ab278ee0..9e4146a5 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt @@ -2,6 +2,7 @@ package com.powersync.bucket import com.powersync.db.crud.CrudEntry import com.powersync.db.internal.PowerSyncTransaction +import com.powersync.sync.Instruction import com.powersync.sync.SyncDataBatch import com.powersync.sync.SyncLocalDatabaseResult @@ -25,20 +26,8 @@ internal interface BucketStorage { suspend fun updateLocalTarget(checkpointCallback: suspend () -> String): Boolean - suspend fun saveSyncData(syncDataBatch: SyncDataBatch) - - suspend fun getBucketStates(): List - - suspend fun getBucketOperationProgress(): Map - - suspend fun removeBuckets(bucketsToDelete: List) - suspend fun hasCompletedSync(): Boolean - suspend fun syncLocalDatabase( - targetCheckpoint: Checkpoint, - partialPriority: BucketPriority? = null, - ): SyncLocalDatabaseResult - - fun setTargetCheckpoint(checkpoint: Checkpoint) + suspend fun control(op: String, payload: String?): List + suspend fun control(op: String, payload: ByteArray): List } diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt index 2c3b10cd..cb043d86 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt @@ -8,6 +8,7 @@ import com.powersync.db.crud.CrudRow import com.powersync.db.internal.InternalDatabase import com.powersync.db.internal.InternalTable import com.powersync.db.internal.PowerSyncTransaction +import com.powersync.sync.Instruction import com.powersync.sync.SyncDataBatch import com.powersync.sync.SyncLocalDatabaseResult import com.powersync.utils.JsonUtil @@ -21,14 +22,8 @@ internal class BucketStorageImpl( private var hasCompletedSync = AtomicBoolean(false) 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 } override fun getMaxOpId(): String = MAX_OP_ID @@ -130,50 +125,6 @@ internal class BucketStorageImpl( } } - override suspend fun saveSyncData(syncDataBatch: SyncDataBatch) { - db.writeTransaction { tx -> - val jsonString = JsonUtil.json.encodeToString(syncDataBatch) - tx.execute( - "INSERT INTO powersync_operations(op, data) VALUES(?, ?)", - listOf("save", jsonString), - ) - } - this.compactCounter += syncDataBatch.buckets.sumOf { it.data.size } - } - - override suspend fun getBucketStates(): List = - db.getAll( - "SELECT name AS bucket, CAST(last_op AS TEXT) AS op_id FROM ${InternalTable.BUCKETS} WHERE pending_delete = 0 AND name != '\$local'", - mapper = { cursor -> - BucketState( - bucket = cursor.getString(0)!!, - opId = cursor.getString(1)!!, - ) - }, - ) - - override suspend fun getBucketOperationProgress(): Map = - buildMap { - val rows = - db.getAll("SELECT name, count_at_last, count_since_last FROM ps_buckets") { cursor -> - cursor.getString(0)!! to - LocalOperationCounters( - atLast = cursor.getLong(1)!!.toInt(), - sinceLast = cursor.getLong(2)!!.toInt(), - ) - } - - for ((name, counters) in rows) { - put(name, counters) - } - } - - override suspend fun removeBuckets(bucketsToDelete: List) { - bucketsToDelete.forEach { bucketName -> - deleteBucket(bucketName) - } - } - private suspend fun deleteBucket(bucketName: String) { db.writeTransaction { tx -> tx.execute( @@ -208,202 +159,26 @@ internal class BucketStorageImpl( } } - override suspend fun syncLocalDatabase( - targetCheckpoint: Checkpoint, - partialPriority: BucketPriority?, - ): SyncLocalDatabaseResult { - val result = validateChecksums(targetCheckpoint, partialPriority) - - if (!result.checkpointValid) { - logger.w { "[SyncLocalDatabase] Checksums failed for ${result.checkpointFailures}" } - result.checkpointFailures?.forEach { bucketName -> - deleteBucket(bucketName) - } - result.ready = false - return result - } - - val bucketNames = - targetCheckpoint.checksums - .let { - if (partialPriority == null) { - it - } else { - it.filter { cs -> cs.priority >= partialPriority } - } - }.map { it.bucket } - - db.writeTransaction { tx -> - tx.execute( - "UPDATE ps_buckets SET last_op = ? WHERE name IN (SELECT json_each.value FROM json_each(?))", - listOf(targetCheckpoint.lastOpId, JsonUtil.json.encodeToString(bucketNames)), - ) - - if (partialPriority == null && targetCheckpoint.writeCheckpoint != null) { - tx.execute( - "UPDATE ps_buckets SET last_op = ? WHERE name = '\$local'", - listOf(targetCheckpoint.writeCheckpoint), - ) - } - } - - val valid = updateObjectsFromBuckets(targetCheckpoint, partialPriority) - - if (!valid) { - return SyncLocalDatabaseResult( - ready = false, - checkpointValid = true, - ) - } - - this.forceCompact() - - return SyncLocalDatabaseResult( - ready = true, - ) - } - - private suspend fun validateChecksums( - checkpoint: Checkpoint, - priority: BucketPriority? = null, - ): SyncLocalDatabaseResult { - val serializedCheckpoint = - JsonUtil.json.encodeToString( - when (priority) { - null -> checkpoint - // Only validate buckets with a priority included in this partial sync. - else -> checkpoint.copy(checksums = checkpoint.checksums.filter { it.priority >= priority }) - }, - ) - - val res = - db.getOptional( - "SELECT powersync_validate_checkpoint(?) AS result", - parameters = listOf(serializedCheckpoint), - mapper = { cursor -> - cursor.getString(0)!! - }, - ) - ?: // no result - return SyncLocalDatabaseResult( - ready = false, - checkpointValid = false, - ) + private fun handleControlResult(cursor: SqlCursor): List { + val result = cursor.getString(0)!! + logger.v { "control result: $result" } - return JsonUtil.json.decodeFromString(res) + return JsonUtil.json.decodeFromString>(result) } - /** - * Atomically update the local state. - * - * This includes creating new tables, dropping old tables, and copying data over from the oplog. - */ - private suspend fun updateObjectsFromBuckets( - checkpoint: Checkpoint, - priority: BucketPriority? = null, - ): Boolean { - @Serializable - data class SyncLocalArgs( - val priority: BucketPriority, - val buckets: List, - ) - - val args = - if (priority != null) { - JsonUtil.json.encodeToString( - SyncLocalArgs( - priority = priority, - buckets = checkpoint.checksums.filter { it.priority >= priority }.map { it.bucket }, - ), - ) - } else { - "" - } - + override suspend fun control(op: String, payload: String?): List { return db.writeTransaction { tx -> - tx.execute( - "INSERT INTO powersync_operations(op, data) VALUES(?, ?)", - listOf("sync_local", args), - ) - - val res = - tx.get("select last_insert_rowid()") { cursor -> - cursor.getLong(0)!! - } - - val didApply = res == 1L - if (didApply && priority == null) { - // Reset progress counters. We only do this for a complete sync, as we want a download progress to - // always cover a complete checkpoint instead of resetting for partial completions. - tx.execute( - """ - UPDATE ps_buckets SET count_since_last = 0, count_at_last = ?1->name - WHERE ?1->name IS NOT NULL - """.trimIndent(), - listOf( - JsonUtil.json.encodeToString( - buildMap { - for (bucket in checkpoint.checksums) { - bucket.count?.let { put(bucket.bucket, it) } - } - }, - ), - ), - ) - } - - return@writeTransaction didApply - } - } + logger.v { "powersync_control($op, $payload)" } - private suspend fun forceCompact() { - // Reset counter - this.compactCounter = COMPACT_OPERATION_INTERVAL - this.pendingBucketDeletes.value = true - - this.autoCompact() - } - - private 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 { tx -> - tx.execute( - "INSERT INTO powersync_operations(op, data) VALUES (?, ?)", - listOf("delete_pending_buckets", ""), - ) - - // Executed once after start-up, and again when there are pending deletes. - pendingBucketDeletes.value = false + tx.get("SELECT powersync_control(?, ?) AS r", listOf(op, payload), ::handleControlResult) } } - private suspend fun clearRemoveOps() { - if (this.compactCounter < COMPACT_OPERATION_INTERVAL) { - return - } + override suspend fun control(op: String, payload: ByteArray): List { + return db.writeTransaction { tx -> + logger.v { "powersync_control($op, binary payload)" } - db.writeTransaction { tx -> - tx.execute( - "INSERT INTO powersync_operations(op, data) VALUES (?, ?)", - listOf("clear_remove_ops", ""), - ) + tx.get("SELECT powersync_control(?, ?) AS r", listOf(op, payload), ::handleControlResult) } - this.compactCounter = 0 - } - - @Suppress("UNUSED_PARAMETER") - override fun setTargetCheckpoint(checkpoint: Checkpoint) { - // No-op for now } } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt index 1c44440a..5379c4bb 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt @@ -1,7 +1,13 @@ package com.powersync.sync +import com.powersync.bucket.BucketPriority +import kotlinx.datetime.Instant import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.Decoder @@ -21,24 +27,36 @@ internal sealed interface Instruction { @Serializable data class EstablishSyncStream(val request: JsonObject): Instruction - object FlushSileSystem: Instruction - object CloseSyncStream: Instruction - object UnknownInstruction: Instruction + @Serializable + data class FetchCredentials( + @SerialName("did_expire") + val didExpire: Boolean + ): Instruction + + data object FlushSileSystem: Instruction + data object CloseSyncStream: Instruction + data object DidCompleteSync: Instruction + + data object UnknownInstruction: Instruction class Serializer : KSerializer { private val logLine = serializer() private val updateSyncStatus = serializer() private val establishSyncStream = serializer() - private val flushFileSystem = buildClassSerialDescriptor(FlushSileSystem::class.qualifiedName!!) {} - private val closeSyncStream = buildClassSerialDescriptor(CloseSyncStream::class.qualifiedName!!) {} + private val fetchCredentials = serializer() + private val flushFileSystem = serializer() + private val closeSyncStream = serializer() + private val didCompleteSync = serializer() override val descriptor = buildClassSerialDescriptor(SyncLine::class.qualifiedName!!) { element("LogLine", logLine.descriptor, isOptional = true) element("UpdateSyncStatus", updateSyncStatus.descriptor, isOptional = true) element("EstablishSyncStream", establishSyncStream.descriptor, isOptional = true) - element("FlushFileSystem", flushFileSystem, isOptional = true) - element("CloseSyncStream", closeSyncStream, isOptional = true) + element("FetchCredentials", fetchCredentials.descriptor, isOptional = true) + element("FlushFileSystem", flushFileSystem.descriptor, isOptional = true) + element("CloseSyncStream", closeSyncStream.descriptor, isOptional = true) + element("DidCompleteSync", didCompleteSync.descriptor, isOptional = true) } override fun deserialize(decoder: Decoder): Instruction = @@ -48,8 +66,19 @@ internal sealed interface Instruction { 0 -> decodeSerializableElement(descriptor, 0, logLine) 1 -> decodeSerializableElement(descriptor, 1, updateSyncStatus) 2 -> decodeSerializableElement(descriptor, 2, establishSyncStream) - 3 -> FlushSileSystem - 4 -> CloseSyncStream + 3 -> decodeSerializableElement(descriptor, 3, fetchCredentials) + 4 -> { + decodeSerializableElement(descriptor, 3, flushFileSystem) + FlushSileSystem + } + 5 -> { + decodeSerializableElement(descriptor, 4, closeSyncStream) + CloseSyncStream + } + 6 -> { + decodeSerializableElement(descriptor, 4, didCompleteSync) + DidCompleteSync + } CompositeDecoder.UNKNOWN_NAME, CompositeDecoder.DECODE_DONE -> UnknownInstruction else -> error("Unexpected index: $index") } @@ -70,4 +99,49 @@ internal sealed interface Instruction { } @Serializable -internal class CoreSyncStatus {} +internal class CoreSyncStatus( + val connected: Boolean, + val connecting: Boolean, + val downloading: CoreDownloadProgress?, + @SerialName("priority_status") + val priorityStatus: List, +) + +@Serializable +internal class CoreDownloadProgress( + val buckets: Map +) + +@Serializable +internal class CoreBucketProgress( + val priority: BucketPriority, + @SerialName("at_last") + val atLast: Long, + @SerialName("since_last") + val sinceLast: Long, + @SerialName("target_count") + val targetCount: Long +) + +@Serializable +internal class CorePriorityStatus( + val priority: BucketPriority, + @SerialName("last_synced_at") + @Serializable(with = InstantTimestampSerializer::class) + val lastSyncedAt: Instant?, + @SerialName("has_synced") + val hasSynced: Boolean? +) + +private object InstantTimestampSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = PrimitiveSerialDescriptor("kotlinx.datetime.Instant", PrimitiveKind.LONG) + + override fun deserialize(decoder: Decoder): Instant { + return Instant.fromEpochSeconds(decoder.decodeLong()) + } + + override fun serialize(encoder: Encoder, value: Instant) { + encoder.encodeLong(value.epochSeconds) + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt index 1d2158d7..f01c6f64 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt @@ -61,8 +61,8 @@ internal data class ProgressInfo( * one-by-one. */ @ConsistentCopyVisibility -public data class SyncDownloadProgress private constructor( - private val buckets: Map, +public data class SyncDownloadProgress internal constructor( + private val buckets: Map, ): ProgressWithOperations { override val downloadedOperations: Int @@ -74,28 +74,6 @@ public data class SyncDownloadProgress private constructor( downloadedOperations = completed } - /** - * Creates download progress information from the local progress counters since the last full sync and the target - * checkpoint. - */ - internal constructor(localProgress: Map, target: Checkpoint) : this( - buildMap { - for (entry in target.checksums) { - val savedProgress = localProgress[entry.bucket] - - put( - entry.bucket, - BucketProgress( - priority = entry.priority, - atLast = savedProgress?.atLast ?: 0, - sinceLast = savedProgress?.sinceLast ?: 0, - targetCount = entry.count ?: 0, - ), - ) - } - }, - ) - /** * Returns download progress towards all data up until the specified [priority] being received. * @@ -107,37 +85,12 @@ public data class SyncDownloadProgress private constructor( return ProgressInfo(totalOperations = total, downloadedOperations = completed) } - internal fun incrementDownloaded(batch: SyncDataBatch): SyncDownloadProgress = - SyncDownloadProgress( - buildMap { - putAll(this@SyncDownloadProgress.buckets) - - for (bucket in batch.buckets) { - val previous = get(bucket.bucket) ?: continue - put( - bucket.bucket, - previous.copy( - sinceLast = previous.sinceLast + bucket.data.size, - ), - ) - } - }, - ) - private fun targetAndCompletedCounts(priority: BucketPriority): Pair = buckets.values .asSequence() .filter { it.priority >= priority } - .fold(0 to 0) { (prevTarget, prevCompleted), entry -> - (prevTarget + entry.total) to (prevCompleted + entry.sinceLast) + .fold(0L to 0L) { (prevTarget, prevCompleted), entry -> + (prevTarget + entry.targetCount) to (prevCompleted + entry.sinceLast) } -} - -private data class BucketProgress( - val priority: BucketPriority, - val atLast: Int, - val sinceLast: Int, - val targetCount: Int, -) { - val total get(): Int = targetCount - atLast + .let { it.first.toInt() to it.second.toInt() } } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt index 067080b9..1471d387 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt @@ -127,20 +127,23 @@ internal data class SyncStatusDataContainer( override val anyError get() = downloadError ?: uploadError - internal fun abortedDownload() = - copy( - downloading = false, - downloadProgress = null, - ) - - internal fun copyWithCompletedDownload() = - copy( - lastSyncedAt = Clock.System.now(), - downloading = false, - downloadProgress = null, - hasSynced = true, - downloadError = null, + internal fun applyCoreChanges(status: CoreSyncStatus): SyncStatusDataContainer { + val completeSync = status.priorityStatus.firstOrNull { it.priority == BucketPriority.FULL_SYNC_PRIORITY } + + return copy( + connected = status.connected, + connecting = status.connecting, + downloading = status.downloading != null, + downloadProgress = status.downloading?.let { SyncDownloadProgress(it.buckets) }, + lastSyncedAt = completeSync?.lastSyncedAt, + hasSynced = completeSync != null, + priorityStatusEntries = status.priorityStatus.map { PriorityStatusEntry( + priority = it.priority, + lastSyncedAt = it.lastSyncedAt, + hasSynced = it.hasSynced, + ) } ) + } } @ConsistentCopyVisibility diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index a89cb660..3f8aa963 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -1,6 +1,8 @@ package com.powersync.sync import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import co.touchlab.stately.concurrency.AtomicBoolean import co.touchlab.stately.concurrency.AtomicReference import com.powersync.bucket.BucketChecksum import com.powersync.bucket.BucketRequest @@ -31,10 +33,16 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject @@ -49,12 +57,13 @@ internal class SyncStream( private val scope: CoroutineScope, createClient: (HttpClientConfig<*>.() -> Unit) -> HttpClient, ) { - private var isUploadingCrud = AtomicReference(null) + private var isUploadingCrud = AtomicBoolean(false) + private val completedCrudUploads = Channel(onBufferOverflow = BufferOverflow.DROP_OLDEST) /** - * The current sync status. This instance is updated as changes occur + * The current sync status. This instance is mutated as changes occur */ - var status = SyncStatus() + val status = SyncStatus() private var clientId: String? = null @@ -97,22 +106,15 @@ internal class SyncStream( fun triggerCrudUploadAsync(): Job = scope.launch { - val thisIteration = PendingCrudUpload(CompletableDeferred()) - var holdingUploadLock = false + if (!status.connected || !isUploadingCrud.compareAndSet(expected = false, new = true)) { + return@launch + } try { - if (!status.connected || !isUploadingCrud.compareAndSet(null, thisIteration)) { - return@launch - } - - holdingUploadLock = true uploadAllCrud() + completedCrudUploads.send(Unit) } finally { - if (holdingUploadLock) { - isUploadingCrud.set(null) - } - - thisIteration.done.complete(Unit) + isUploadingCrud.value = false } } @@ -182,7 +184,7 @@ internal class SyncStream( return body.data.writeCheckpoint } - private fun streamingSyncRequest(req: StreamingSyncRequest): Flow = + private fun streamingSyncRequest(req: JsonObject): Flow = flow { val credentials = connector.getCredentialsCached() require(credentials != null) { "Not logged in" } @@ -223,242 +225,94 @@ internal class SyncStream( } } - private suspend fun streamingSyncIteration(): SyncStreamState { - val bucketEntries = bucketStorage.getBucketStates() - val initialBuckets = mutableMapOf() - - var state = - SyncStreamState( - targetCheckpoint = null, - validatedCheckpoint = null, - appliedCheckpoint = null, - bucketSet = initialBuckets.keys.toMutableSet(), - ) - - bucketEntries.forEach { entry -> - initialBuckets[entry.bucket] = entry.opId - } + private suspend fun streamingSyncIteration() { + val iteration = ActiveIteration() - val req = - StreamingSyncRequest( - buckets = initialBuckets.map { (bucket, after) -> BucketRequest(bucket, after) }, - clientId = clientId!!, - parameters = params, - ) - - streamingSyncRequest(req).collect { value -> - val line = JsonUtil.json.decodeFromString(value) - - state = handleInstruction(line, value, state) - - if (state.abortIteration) { - return@collect + try { + iteration.start() + } finally { + // This can't be cancelled because we need to send a stop message, which is async, to + // clean up resources. + withContext(NonCancellable) { + iteration.stop() } } - - status.update { abortedDownload() } - - return state } - private suspend fun handleInstruction( - line: SyncLine, - jsonString: String, - state: SyncStreamState, - ): SyncStreamState = - when (line) { - is SyncLine.FullCheckpoint -> handleStreamingSyncCheckpoint(line, state) - is SyncLine.CheckpointDiff -> handleStreamingSyncCheckpointDiff(line, state) - is SyncLine.CheckpointComplete -> handleStreamingSyncCheckpointComplete(state) - is SyncLine.CheckpointPartiallyComplete -> - handleStreamingSyncCheckpointPartiallyComplete( - line, - state, - ) - - is SyncLine.KeepAlive -> handleStreamingKeepAlive(line, state) - is SyncLine.SyncDataBucket -> handleStreamingSyncData(line, state) - SyncLine.UnknownSyncLine -> { - logger.w { "Unhandled instruction $jsonString" } - state - } - } - - private suspend fun handleStreamingSyncCheckpoint( - line: SyncLine.FullCheckpoint, - state: SyncStreamState, - ): SyncStreamState { - val (checkpoint) = line - state.targetCheckpoint = checkpoint - - val bucketsToDelete = state.bucketSet!!.toMutableList() - val newBuckets = mutableSetOf() - - checkpoint.checksums.forEach { checksum -> - run { - newBuckets.add(checksum.bucket) - bucketsToDelete.remove(checksum.bucket) - } - } - - state.bucketSet = newBuckets - startTrackingCheckpoint(checkpoint, bucketsToDelete) - - return state - } - - private suspend fun startTrackingCheckpoint( - checkpoint: Checkpoint, - bucketsToDelete: List, + private inner class ActiveIteration( + var fetchLinesJob: Job? = null, ) { - val progress = bucketStorage.getBucketOperationProgress() - status.update { - copy( - downloading = true, - downloadProgress = SyncDownloadProgress(progress, checkpoint), - ) + suspend fun start() { + control("start", JsonUtil.json.encodeToString(params)) + fetchLinesJob?.join() } - if (bucketsToDelete.isNotEmpty()) { - logger.i { "Removing buckets [${bucketsToDelete.joinToString(separator = ", ")}]" } + suspend fun stop() { + control("stop") + fetchLinesJob?.join() } - bucketStorage.removeBuckets(bucketsToDelete) - bucketStorage.setTargetCheckpoint(checkpoint) - } - - private suspend fun handleStreamingSyncCheckpointComplete(state: SyncStreamState): SyncStreamState { - val checkpoint = state.targetCheckpoint!! - var result = bucketStorage.syncLocalDatabase(checkpoint) - val pending = isUploadingCrud.get() - - if (!result.checkpointValid) { - // This means checksums failed. Start again with a new checkpoint. - // TODO: better back-off - delay(50) - state.abortIteration = true - // TODO handle retries - return state - } else if (!result.ready && pending != null) { - // We have pending entries in the local upload queue or are waiting to confirm a write checkpoint, which - // prevented this checkpoint from applying. Wait for that to complete and try again. - logger.d { "Could not apply checkpoint due to local data. Waiting for in-progress upload before retrying." } - pending.done.await() - - result = bucketStorage.syncLocalDatabase(checkpoint) + private suspend fun control(op: String, payload: String? = null) { + val instructions = bucketStorage.control(op, payload) + handleInstructions(instructions) } - if (result.checkpointValid && result.ready) { - state.appliedCheckpoint = checkpoint.clone() - logger.i { "validated checkpoint ${state.appliedCheckpoint}" } - - state.validatedCheckpoint = state.targetCheckpoint - status.update { copyWithCompletedDownload() } - } else { - logger.d { "Could not apply checkpoint. Waiting for next sync complete line" } + private suspend fun handleInstructions(instructions: List) { + instructions.forEach { handleInstruction(it) } } - return state - } - - private suspend fun handleStreamingSyncCheckpointPartiallyComplete( - line: SyncLine.CheckpointPartiallyComplete, - state: SyncStreamState, - ): SyncStreamState { - val priority = line.priority - val result = bucketStorage.syncLocalDatabase(state.targetCheckpoint!!, priority) - if (!result.checkpointValid) { - // This means checksums failed. Start again with a new checkpoint. - // TODO: better back-off - delay(50) - state.abortIteration = true - // TODO handle retries - return state - } else if (!result.ready) { - // Checkpoint is valid, but we have local data preventing this to be published. We'll try to resolve this - // once we have a complete checkpoint if the problem persists. - } else { - logger.i { "validated partial checkpoint ${state.appliedCheckpoint} up to priority of $priority" } - } - - status.update { - copy( - priorityStatusEntries = - buildList { - // All states with a higher priority can be deleted since this partial sync includes them. - addAll(status.priorityStatusEntries.filter { it.priority >= line.priority }) - add( - PriorityStatusEntry( - priority = priority, - lastSyncedAt = Clock.System.now(), - hasSynced = true, - ), - ) - }, - ) - } - return state - } - - private suspend fun handleStreamingSyncCheckpointDiff( - checkpointDiff: SyncLine.CheckpointDiff, - 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") + private suspend fun handleInstruction(instruction: Instruction) { + when (instruction) { + is Instruction.EstablishSyncStream -> { + fetchLinesJob?.cancelAndJoin() + fetchLinesJob = scope.launch { + launch { + for (completion in completedCrudUploads) { + control("completed_upload") + } + } + + launch { + connect(instruction) + } + } + } + Instruction.CloseSyncStream -> { + fetchLinesJob!!.cancelAndJoin() + fetchLinesJob = null + } + Instruction.FlushSileSystem -> { + // We have durable file systems, so flushing is not necessary + } + is Instruction.LogLine -> { + logger.log( + severity = when(instruction.severity) { + "DEBUG" -> Severity.Debug + "INFO" -> Severity.Debug + else -> Severity.Warn + }, + message = instruction.line, + tag = logger.tag, + throwable = null, + ) + } + is Instruction.UpdateSyncStatus -> { + status.update { + applyCoreChanges(instruction.status) + } + } + is Instruction.FetchCredentials -> TODO() + Instruction.DidCompleteSync -> status.update { copy(downloadError=null) } + Instruction.UnknownInstruction -> TODO() + } } - val newBuckets = mutableMapOf() - - state.targetCheckpoint!!.checksums.forEach { checksum -> - newBuckets[checksum.bucket] = checksum - } - checkpointDiff.updatedBuckets.forEach { checksum -> - newBuckets[checksum.bucket] = checksum + private suspend fun connect(start: Instruction.EstablishSyncStream) { + streamingSyncRequest(start.request).collect { rawLine -> + control("line_text", rawLine) + } } - checkpointDiff.removedBuckets.forEach { bucket -> newBuckets.remove(bucket) } - - val newCheckpoint = - Checkpoint( - lastOpId = checkpointDiff.lastOpId, - checksums = newBuckets.values.toList(), - writeCheckpoint = checkpointDiff.writeCheckpoint, - ) - - state.targetCheckpoint = newCheckpoint - startTrackingCheckpoint(newCheckpoint, checkpointDiff.removedBuckets) - - return state - } - - private suspend fun handleStreamingSyncData( - data: SyncLine.SyncDataBucket, - state: SyncStreamState, - ): SyncStreamState { - val batch = SyncDataBatch(listOf(data)) - status.update { copy(downloading = true, downloadProgress = downloadProgress?.incrementDownloaded(batch)) } - bucketStorage.saveSyncData(batch) - return state - } - - private suspend fun handleStreamingKeepAlive( - keepAlive: SyncLine.KeepAlive, - state: SyncStreamState, - ): SyncStreamState { - val (tokenExpiresIn) = keepAlive - - if (tokenExpiresIn <= 0) { - // Connection would be closed automatically right after this - logger.i { "Token expiring reconnect" } - connector.invalidateCredentials() - state.abortIteration = true - return state - } - // Don't await the upload job, we can keep receiving sync lines - triggerCrudUploadAsync() - return state } internal companion object { @@ -468,15 +322,3 @@ internal class SyncStream( } } } - -internal data class SyncStreamState( - var targetCheckpoint: Checkpoint?, - var validatedCheckpoint: Checkpoint?, - var appliedCheckpoint: Checkpoint?, - var bucketSet: MutableSet?, - var abortIteration: Boolean = false, -) - -private class PendingCrudUpload( - val done: CompletableDeferred, -) diff --git a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt index 0347d967..5d1535c6 100644 --- a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt @@ -148,27 +148,6 @@ class BucketStorageTest { assertTrue(result) } - @Test - fun testGetBucketStates() = - runTest { - val mockBucketStates = listOf(BucketState("bucket1", "op1"), BucketState("bucket2", "op2")) - mockDb = - mock { - everySuspend { - getOptional( - any(), - any(), - any(), - ) - } returns 1L - everySuspend { getAll(any(), any(), any()) } returns mockBucketStates - } - bucketStorage = BucketStorageImpl(mockDb, Logger) - - val result = bucketStorage.getBucketStates() - assertEquals(mockBucketStates, result) - } - // TODO: Add tests for removeBuckets, hasCompletedSync, syncLocalDatabase currently not covered because // currently the internal methods are private and cannot be accessed from the test class } diff --git a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt index a3f9cf18..e6984c7c 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -71,17 +71,6 @@ class SyncStreamTest { bucketStorage = mock { everySuspend { getClientId() } returns "test-client-id" - everySuspend { getBucketStates() } returns emptyList() - everySuspend { removeBuckets(any()) } returns Unit - everySuspend { setTargetCheckpoint(any()) } returns Unit - everySuspend { saveSyncData(any()) } returns Unit - everySuspend { syncLocalDatabase(any(), any()) } returns - SyncLocalDatabaseResult( - ready = true, - checkpointValid = true, - checkpointFailures = emptyList(), - ) - everySuspend { getBucketOperationProgress() } returns mapOf() } connector = mock { @@ -175,7 +164,6 @@ class SyncStreamTest { bucketStorage = mock { everySuspend { getClientId() } returns "test-client-id" - everySuspend { getBucketStates() } returns emptyList() } syncStream = @@ -210,123 +198,4 @@ class SyncStreamTest { // Clean up job.cancel() } - - @Test - fun testPartialSync() = - runTest { - // TODO: It would be neat if we could use in-memory sqlite instances instead of mocking everything - // Revisit https://github.com/powersync-ja/powersync-kotlin/pull/117/files at some point - val syncLines = Channel() - val client = MockSyncService(syncLines, { WriteCheckpointResponse(WriteCheckpointData("1000")) }) - - syncStream = - SyncStream( - bucketStorage = bucketStorage, - connector = connector, - createClient = { config -> HttpClient(client, config) }, - uploadCrud = { }, - retryDelayMs = 10, - logger = logger, - params = JsonObject(emptyMap()), - scope = this, - ) - - val job = launch { syncStream.streamingSync() } - var operationId = 1 - - suspend fun pushData(priority: Int) { - val id = operationId++ - - syncLines.send( - SyncLine.SyncDataBucket( - bucket = "prio$priority", - data = - listOf( - OplogEntry( - checksum = (priority + 10).toLong(), - data = JsonUtil.json.encodeToString(mapOf("foo" to "bar")), - op = OpType.PUT, - opId = id.toString(), - rowId = "prio$priority", - rowType = "customers", - ), - ), - after = null, - nextAfter = null, - ), - ) - } - - turbineScope(timeout = 10.0.seconds) { - val turbine = syncStream.status.asFlow().testIn(this) - turbine.waitFor { it.connected } - resetCalls(bucketStorage) - - // Start a sync flow - syncLines.send( - SyncLine.FullCheckpoint( - Checkpoint( - lastOpId = "4", - checksums = - buildList { - for (priority in 0..3) { - add( - BucketChecksum( - bucket = "prio$priority", - priority = BucketPriority(priority), - checksum = 10 + priority, - ), - ) - } - }, - ), - ), - ) - - // Emit a partial sync complete for each priority but the last. - for (priorityNo in 0..<3) { - val priority = BucketPriority(priorityNo) - pushData(priorityNo) - syncLines.send( - SyncLine.CheckpointPartiallyComplete( - lastOpId = operationId.toString(), - priority = priority, - ), - ) - - turbine.waitFor { it.statusForPriority(priority).hasSynced == true } - - verifySuspend(order) { - if (priorityNo == 0) { - bucketStorage.getBucketOperationProgress() - bucketStorage.removeBuckets(any()) - bucketStorage.setTargetCheckpoint(any()) - } - - bucketStorage.saveSyncData(any()) - bucketStorage.syncLocalDatabase(any(), priority) - } - } - - // Then complete the sync - pushData(3) - syncLines.send( - SyncLine.CheckpointComplete( - lastOpId = operationId.toString(), - ), - ) - - turbine.waitFor { it.hasSynced == true } - verifySuspend { - bucketStorage.saveSyncData(any()) - bucketStorage.syncLocalDatabase(any(), null) - } - - turbine.cancel() - } - - verifyNoMoreCalls(bucketStorage) - job.cancel() - syncLines.close() - } } From a472d5041bb5d9b121a5875d45d6647a55509ecc Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 29 Apr 2025 16:26:23 +0200 Subject: [PATCH 03/16] Start with rsocket support --- core/build.gradle.kts | 1 + .../kotlin/com/powersync/PowerSyncDatabase.kt | 2 + .../com/powersync/db/PowerSyncDatabaseImpl.kt | 4 +- .../kotlin/com/powersync/sync/SyncOptions.kt | 37 ++++++++++ .../kotlin/com/powersync/sync/SyncStream.kt | 69 ++++++++++++++++++- .../com/powersync/sync/SyncStreamTest.kt | 3 + gradle/libs.versions.toml | 4 +- 7 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b3b1e3fa..19f3d8d0 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -174,6 +174,7 @@ kotlin { implementation(libs.ktor.client.contentnegotiation) implementation(libs.ktor.serialization.json) implementation(libs.kotlinx.io) + implementation(libs.rsocket.client) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.stately.concurrency) diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 43f278a3..769aa312 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -6,6 +6,7 @@ import com.powersync.db.Queries import com.powersync.db.crud.CrudBatch import com.powersync.db.crud.CrudTransaction import com.powersync.db.schema.Schema +import com.powersync.sync.SyncOptions import com.powersync.sync.SyncStatus import com.powersync.utils.JsonParam import kotlin.coroutines.cancellation.CancellationException @@ -94,6 +95,7 @@ public interface PowerSyncDatabase : Queries { crudThrottleMs: Long = 1000L, retryDelayMs: Long = 5000L, params: Map = emptyMap(), + options: SyncOptions = SyncOptions() ) /** diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 56815872..0a2a9cfb 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -18,6 +18,7 @@ import com.powersync.db.internal.PowerSyncVersion import com.powersync.db.schema.Schema import com.powersync.db.schema.toSerializable import com.powersync.sync.PriorityStatusEntry +import com.powersync.sync.SyncOptions import com.powersync.sync.SyncStatus import com.powersync.sync.SyncStatusData import com.powersync.sync.SyncStream @@ -153,6 +154,7 @@ internal class PowerSyncDatabaseImpl( crudThrottleMs: Long, retryDelayMs: Long, params: Map, + options: SyncOptions ) { waitReady() mutex.withLock { @@ -168,13 +170,13 @@ internal class PowerSyncDatabaseImpl( params = params.toJsonObject(), scope = scope, createClient = createClient, + options = options, ), crudThrottleMs, ) } } - @OptIn(FlowPreview::class) internal fun connectInternal( stream: SyncStream, crudThrottleMs: Long, diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt new file mode 100644 index 00000000..4a5cc0c8 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -0,0 +1,37 @@ +package com.powersync.sync + +import io.rsocket.kotlin.keepalive.KeepAlive +import kotlin.time.Duration.Companion.seconds + +public class SyncOptions( + public val method: ConnectionMethod = ConnectionMethod.WebSocket, +) + +/** + * The connection method to use when the SDK connects to the sync service. + */ +public sealed interface ConnectionMethod { + /** + * Receive sync lines via an streamed HTTP response from the sync service. + * + * This mode is less efficient than [WebSocket] because it doesn't support backpressure + * properly and uses JSON instead of the more efficient BSON representation for sync lines. + */ + public data object Http: ConnectionMethod + + /** + * Receive binary sync lines via RSocket over a WebSocket connection. + * + * This is the default mode, and recommended for most clients. + */ + public data class WebSocket( + val keepAlive: KeepAlive = DefaultKeepAlive + ): ConnectionMethod { + private companion object { + val DefaultKeepAlive = KeepAlive( + interval = 20.0.seconds, + maxLifetime = 30.0.seconds, + ) + } + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 3f8aa963..02657639 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -1,5 +1,6 @@ package com.powersync.sync +import BuildConfig import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.stately.concurrency.AtomicBoolean @@ -18,6 +19,7 @@ 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.plugins.websocket.WebSockets import io.ktor.client.request.get import io.ktor.client.request.headers import io.ktor.client.request.preparePost @@ -26,9 +28,21 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.URLBuilder +import io.ktor.http.URLProtocol +import io.ktor.http.Url import io.ktor.http.contentType +import io.ktor.http.takeFrom import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readUTF8Line +import io.rsocket.kotlin.keepalive.KeepAlive +import io.rsocket.kotlin.ktor.client.RSocketSupport +import io.rsocket.kotlin.ktor.client.rSocket +import io.rsocket.kotlin.payload.Payload +import io.rsocket.kotlin.payload.PayloadMimeType +import io.rsocket.kotlin.payload.buildPayload +import io.rsocket.kotlin.payload.data +import io.rsocket.kotlin.payload.metadata import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -44,8 +58,11 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject +import kotlin.time.Duration.Companion.seconds internal class SyncStream( private val bucketStorage: BucketStorage, @@ -55,6 +72,7 @@ internal class SyncStream( private val logger: Logger, private val params: JsonObject, private val scope: CoroutineScope, + private val options: SyncOptions, createClient: (HttpClientConfig<*>.() -> Unit) -> HttpClient, ) { private var isUploadingCrud = AtomicBoolean(false) @@ -71,6 +89,37 @@ internal class SyncStream( createClient { install(HttpTimeout) install(ContentNegotiation) + + (options.method as? ConnectionMethod.WebSocket)?.let { + install(WebSockets) + install(RSocketSupport) { + connector { + connectionConfig { + payloadMimeType = PayloadMimeType( + metadata = "application/json", + data = "application/json" + ) + + setupPayload { + buildPayload { + @Serializable + class ConnectionSetupMetadata( + // Kind of annoying to specify this here, https://github.com/rsocket/rsocket-kotlin/issues/311 + val token: String = "TODO: token", + @SerialName("user_agent") + val userAgent: String = "Kotlin SDK" + ) + + metadata(JsonUtil.json.encodeToString(ConnectionSetupMetadata())) + } + } + + keepAlive = it.keepAlive + } + } +} + } + } fun invalidateCredentials() { @@ -184,7 +233,7 @@ internal class SyncStream( return body.data.writeCheckpoint } - private fun streamingSyncRequest(req: JsonObject): Flow = + private fun connectViaHttp(req: JsonObject): Flow = flow { val credentials = connector.getCredentialsCached() require(credentials != null) { "Not logged in" } @@ -225,6 +274,22 @@ internal class SyncStream( } } + private fun connectViaWebSocket(req: JsonObject): Flow = flow { + val credentials = connector.getCredentialsCached() + require(credentials != null) { "Not logged in" } + val uri = URLBuilder(credentials.endpointUri("sync/stream")).apply { + protocol = when (protocolOrNull) { + URLProtocol.HTTP -> URLProtocol.WS + else -> URLProtocol.WSS + } + } + + val rSocket = httpClient.rSocket { url.takeFrom(uri) } + rSocket.requestStream(buildPayload { + metadata(JsonUtil.json.encodeToString("")) + }) + } + private suspend fun streamingSyncIteration() { val iteration = ActiveIteration() @@ -308,7 +373,7 @@ internal class SyncStream( } private suspend fun connect(start: Instruction.EstablishSyncStream) { - streamingSyncRequest(start.request).collect { rawLine -> + connectViaHttp(start.request).collect { rawLine -> control("line_text", rawLine) } } diff --git a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt index e6984c7c..7d5cb616 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -100,6 +100,7 @@ class SyncStreamTest { logger = logger, params = JsonObject(emptyMap()), scope = this, + options = SyncOptions(), ) syncStream.invalidateCredentials() @@ -137,6 +138,7 @@ class SyncStreamTest { logger = logger, params = JsonObject(emptyMap()), scope = this, + options = SyncOptions(), ) syncStream.status.update { copy(connected = true) } @@ -176,6 +178,7 @@ class SyncStreamTest { logger = logger, params = JsonObject(emptyMap()), scope = this, + options = SyncOptions() ) // Launch streaming sync in a coroutine that we'll cancel after verification diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 336a60e3..56cb50b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,8 @@ kotlin = "2.1.10" coroutines = "1.8.1" kotlinx-datetime = "0.6.2" kotlinx-io = "0.5.4" -ktor = "3.0.1" +ktor = "3.1.0" +rsocket = "0.20.0" uuid = "0.8.2" powersync-core = "0.3.12" sqlite-jdbc = "3.49.1.0" @@ -85,6 +86,7 @@ ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotia ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +rsocket-client = { module = "io.rsocket.kotlin:ktor-client-rsocket", version.ref = "rsocket" } sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } sqliter = { module = "co.touchlab:sqliter-driver", version.ref = "sqliter" } From e428df3247719eace1cdd1083624134a90c4bb5d Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 6 May 2025 09:37:38 +0200 Subject: [PATCH 04/16] Add RSocket client --- .../connector/supabase/SupabaseConnector.kt | 14 ++- core/build.gradle.kts | 3 +- .../com/powersync/bucket/BucketStorageImpl.kt | 19 ++- .../kotlin/com/powersync/sync/Progress.kt | 2 +- .../com/powersync/sync/RSocketSupport.kt | 111 ++++++++++++++++++ .../kotlin/com/powersync/sync/SyncOptions.kt | 8 +- .../kotlin/com/powersync/sync/SyncStream.kt | 95 ++++++--------- .../kotlin/com/powersync/sync/UserAgent.kt | 3 + .../powersync/DatabaseDriverFactory.jvm.kt | 2 +- .../gradle/libs.versions.toml | 2 +- .../kotlin/com/powersync/demos/Auth.kt | 6 +- gradle/libs.versions.toml | 3 +- 12 files changed, 194 insertions(+), 74 deletions(-) create mode 100644 core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt create mode 100644 core/src/commonMain/kotlin/com/powersync/sync/UserAgent.kt 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 index 388b70e6..c71666fe 100644 --- a/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt +++ b/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt @@ -20,11 +20,14 @@ import io.github.jan.supabase.postgrest.from import io.github.jan.supabase.storage.BucketApi import io.github.jan.supabase.storage.Storage import io.github.jan.supabase.storage.storage +import io.ktor.client.call.body import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.plugin +import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.utils.io.InternalAPI import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json /** @@ -162,12 +165,17 @@ public class SupabaseConnector( supabaseClient.auth.currentSessionOrNull() ?: error("Could not fetch Supabase credentials") - check(session.user != null) { "No user data" } + @Serializable + class TokenResponse( + val token: String + ) + + val src = supabaseClient.httpClient.httpClient.get("http://localhost:6060/api/auth/token").body() // userId is for debugging purposes only PowerSyncCredentials( - endpoint = powerSyncEndpoint, - token = session.accessToken, // Use the access token to authenticate against PowerSync + endpoint = "http://localhost:8080", //powerSyncEndpoint, + token = src.token, //session.accessToken, // Use the access token to authenticate against PowerSync userId = session.user!!.id, ) } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 19f3d8d0..34b79b99 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -174,7 +174,8 @@ kotlin { implementation(libs.ktor.client.contentnegotiation) implementation(libs.ktor.serialization.json) implementation(libs.kotlinx.io) - implementation(libs.rsocket.client) + api(libs.rsocket.core) + implementation(libs.rsocket.transport.websocket) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.stately.concurrency) diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt index cb043d86..483f8a0d 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt @@ -12,6 +12,13 @@ import com.powersync.sync.Instruction import com.powersync.sync.SyncDataBatch import com.powersync.sync.SyncLocalDatabaseResult import com.powersync.utils.JsonUtil +import io.ktor.utils.io.asByteWriteChannel +import io.ktor.utils.io.writeByteArray +import kotlinx.io.Buffer +import kotlinx.io.buffered +import kotlinx.io.files.FileSystem +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -178,7 +185,17 @@ internal class BucketStorageImpl( return db.writeTransaction { tx -> logger.v { "powersync_control($op, binary payload)" } - tx.get("SELECT powersync_control(?, ?) AS r", listOf(op, payload), ::handleControlResult) + try { + tx.get("SELECT powersync_control(?, ?) AS r", listOf(op, payload), ::handleControlResult) + } catch (e: Exception) { + println("Got control exception, writing") + SystemFileSystem.sink(Path("/Users/simon/failing_line.bin")).buffered().apply { + write(payload) + flush() + close() + } + throw e + } } } } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt index f01c6f64..d9bd2d90 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt @@ -90,7 +90,7 @@ public data class SyncDownloadProgress internal constructor( .asSequence() .filter { it.priority >= priority } .fold(0L to 0L) { (prevTarget, prevCompleted), entry -> - (prevTarget + entry.targetCount) to (prevCompleted + entry.sinceLast) + (prevTarget + entry.targetCount - entry.atLast) to (prevCompleted + entry.sinceLast) } .let { it.first.toInt() to it.second.toInt() } } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt b/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt new file mode 100644 index 00000000..8c473580 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt @@ -0,0 +1,111 @@ +package com.powersync.sync + +import com.powersync.connectors.PowerSyncCredentials +import com.powersync.utils.JsonUtil +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.http.URLBuilder +import io.ktor.http.URLProtocol +import io.ktor.http.takeFrom +import io.rsocket.kotlin.core.RSocketConnector +import io.rsocket.kotlin.payload.PayloadMimeType +import io.rsocket.kotlin.payload.buildPayload +import io.rsocket.kotlin.payload.data +import io.rsocket.kotlin.payload.metadata +import io.rsocket.kotlin.transport.RSocketClientTarget +import io.rsocket.kotlin.transport.RSocketConnection +import io.rsocket.kotlin.transport.RSocketTransportApi +import io.rsocket.kotlin.transport.ktor.websocket.internal.KtorWebSocketConnection +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.io.readByteArray +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlin.coroutines.CoroutineContext + +@OptIn(RSocketTransportApi::class) +internal fun HttpClient.rSocketSyncStream( + options: ConnectionMethod.WebSocket, + req: JsonObject, + credentials: PowerSyncCredentials, +): Flow = flow { + val flowContext = currentCoroutineContext() + + val websocketUri = URLBuilder(credentials.endpointUri("sync/stream")).apply { + protocol = when (protocolOrNull) { + URLProtocol.HTTP -> URLProtocol.WS + else -> URLProtocol.WSS + } + } + + // Note: We're using a custom connector here because we need to set options for each request + // without creating a new HTTP client each time. The recommended approach would be to add an + // RSocket extension to the HTTP client, but that only allows us to set the SETUP metadata for + // all connections (bad because we need a short-lived token in there). + // https://github.com/rsocket/rsocket-kotlin/issues/311 + val target = object : RSocketClientTarget { + @RSocketTransportApi + override suspend fun connectClient(): RSocketConnection { + val ws = webSocketSession { + url.takeFrom(websocketUri) + } + return KtorWebSocketConnection(ws) + } + + override val coroutineContext: CoroutineContext + get() = flowContext + } + + val connector = RSocketConnector { + connectionConfig { + payloadMimeType = PayloadMimeType( + metadata = "application/json", + data = "application/json" + ) + + setupPayload { + buildPayload { + data("{}") + metadata(JsonUtil.json.encodeToString(ConnectionSetupMetadata(token="Bearer ${credentials.token}"))) + } + } + + keepAlive = options.keepAlive + } + } + + val rSocket = connector.connect(target) + val syncStream = rSocket.requestStream(buildPayload { + data("{}") + metadata(JsonUtil.json.encodeToString(RequestStreamMetadata("/sync/stream"))) + }) + + emitAll(syncStream.map { it.data.readByteArray() }.flowOn(Dispatchers.IO)) +} + +/** + * The metadata payload we need to use when connecting with RSocket. + * + * This corresponds to `RSocketContextMeta` on the sync service. + */ +@Serializable +private class ConnectionSetupMetadata( + val token: String, + @SerialName("user_agent") + val userAgent: String = userAgent() +) + +/** + * The metadata payload we send for the `REQUEST_STREAM` frame. + */ +@Serializable +private class RequestStreamMetadata( + val path: String +) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index 4a5cc0c8..0429dc3a 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -4,7 +4,7 @@ import io.rsocket.kotlin.keepalive.KeepAlive import kotlin.time.Duration.Companion.seconds public class SyncOptions( - public val method: ConnectionMethod = ConnectionMethod.WebSocket, + public val method: ConnectionMethod = ConnectionMethod.Http, ) /** @@ -12,17 +12,19 @@ public class SyncOptions( */ public sealed interface ConnectionMethod { /** - * Receive sync lines via an streamed HTTP response from the sync service. + * Receive sync lines via streamed HTTP response from the sync service. * * This mode is less efficient than [WebSocket] because it doesn't support backpressure * properly and uses JSON instead of the more efficient BSON representation for sync lines. + * + * This is currently the default, but this will be changed once [WebSocket] support is stable. */ public data object Http: ConnectionMethod /** * Receive binary sync lines via RSocket over a WebSocket connection. * - * This is the default mode, and recommended for most clients. + * This connection mode is currently experimental and requires a recent sync service to work. */ public data class WebSocket( val keepAlive: KeepAlive = DefaultKeepAlive diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 02657639..7de3d87f 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -1,14 +1,9 @@ package com.powersync.sync -import BuildConfig import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.stately.concurrency.AtomicBoolean -import co.touchlab.stately.concurrency.AtomicReference -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 com.powersync.connectors.PowerSyncBackendConnector import com.powersync.db.crud.CrudEntry @@ -16,10 +11,12 @@ import com.powersync.utils.JsonUtil import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.call.body +import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.timeout import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession import io.ktor.client.request.get import io.ktor.client.request.headers import io.ktor.client.request.preparePost @@ -30,39 +27,36 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol -import io.ktor.http.Url import io.ktor.http.contentType import io.ktor.http.takeFrom import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readUTF8Line -import io.rsocket.kotlin.keepalive.KeepAlive -import io.rsocket.kotlin.ktor.client.RSocketSupport -import io.rsocket.kotlin.ktor.client.rSocket -import io.rsocket.kotlin.payload.Payload +import io.rsocket.kotlin.core.RSocketConnector import io.rsocket.kotlin.payload.PayloadMimeType import io.rsocket.kotlin.payload.buildPayload -import io.rsocket.kotlin.payload.data import io.rsocket.kotlin.payload.metadata +import io.rsocket.kotlin.transport.RSocketClientTarget +import io.rsocket.kotlin.transport.RSocketConnection +import io.rsocket.kotlin.transport.RSocketTransportApi +import io.rsocket.kotlin.transport.ktor.websocket.internal.KtorWebSocketConnection import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.datetime.Clock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject -import kotlin.time.Duration.Companion.seconds +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext internal class SyncStream( private val bucketStorage: BucketStorage, @@ -89,37 +83,13 @@ internal class SyncStream( createClient { install(HttpTimeout) install(ContentNegotiation) + install(WebSockets) - (options.method as? ConnectionMethod.WebSocket)?.let { - install(WebSockets) - install(RSocketSupport) { - connector { - connectionConfig { - payloadMimeType = PayloadMimeType( - metadata = "application/json", - data = "application/json" - ) - - setupPayload { - buildPayload { - @Serializable - class ConnectionSetupMetadata( - // Kind of annoying to specify this here, https://github.com/rsocket/rsocket-kotlin/issues/311 - val token: String = "TODO: token", - @SerialName("user_agent") - val userAgent: String = "Kotlin SDK" - ) - - metadata(JsonUtil.json.encodeToString(ConnectionSetupMetadata())) - } - } - - keepAlive = it.keepAlive - } - } -} + install(DefaultRequest) { + headers { + append("User-Agent", userAgent()) + } } - } fun invalidateCredentials() { @@ -274,20 +244,14 @@ internal class SyncStream( } } - private fun connectViaWebSocket(req: JsonObject): Flow = flow { - val credentials = connector.getCredentialsCached() - require(credentials != null) { "Not logged in" } - val uri = URLBuilder(credentials.endpointUri("sync/stream")).apply { - protocol = when (protocolOrNull) { - URLProtocol.HTTP -> URLProtocol.WS - else -> URLProtocol.WSS - } - } + private fun connectViaWebSocket(req: JsonObject, options: ConnectionMethod.WebSocket): Flow = flow { + val credentials = requireNotNull(connector.getCredentialsCached()) { "Not logged in" } - val rSocket = httpClient.rSocket { url.takeFrom(uri) } - rSocket.requestStream(buildPayload { - metadata(JsonUtil.json.encodeToString("")) - }) + emitAll(httpClient.rSocketSyncStream( + options = options, + req = req, + credentials = credentials + )) } private suspend fun streamingSyncIteration() { @@ -322,6 +286,11 @@ internal class SyncStream( handleInstructions(instructions) } + private suspend fun control(op: String, payload: ByteArray) { + val instructions = bucketStorage.control(op, payload) + handleInstructions(instructions) + } + private suspend fun handleInstructions(instructions: List) { instructions.forEach { handleInstruction(it) } } @@ -373,11 +342,15 @@ internal class SyncStream( } private suspend fun connect(start: Instruction.EstablishSyncStream) { - connectViaHttp(start.request).collect { rawLine -> - control("line_text", rawLine) + when (val method = options.method) { + ConnectionMethod.Http -> connectViaHttp(start.request).collect { rawLine -> + control("line_text", rawLine) + } + is ConnectionMethod.WebSocket -> connectViaWebSocket(start.request, method).collect { binaryLine -> + control("line_binary", binaryLine) + } } } - } internal companion object { diff --git a/core/src/commonMain/kotlin/com/powersync/sync/UserAgent.kt b/core/src/commonMain/kotlin/com/powersync/sync/UserAgent.kt new file mode 100644 index 00000000..d75f0400 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/sync/UserAgent.kt @@ -0,0 +1,3 @@ +package com.powersync.sync + +internal fun userAgent(): String = "PowerSync Kotlin SDK" diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 39864b54..bb0a1c5f 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -33,7 +33,7 @@ public actual class DatabaseDriverFactory { migrateDriver(driver, schema) driver.loadExtensions( - powersyncExtension to "sqlite3_powersync_init", + "/Users/simon/src/powersync-sqlite-core/target/debug/libpowersync.dylib" to "sqlite3_powersync_init", ) val mappedDriver = PsSqlDriver(driver = driver) diff --git a/demos/supabase-todolist/gradle/libs.versions.toml b/demos/supabase-todolist/gradle/libs.versions.toml index 81da7d11..59ef9387 100644 --- a/demos/supabase-todolist/gradle/libs.versions.toml +++ b/demos/supabase-todolist/gradle/libs.versions.toml @@ -11,7 +11,7 @@ kotlin = "2.1.10" coroutines = "1.8.1" kotlinx-datetime = "0.6.2" kotlinx-io = "0.5.4" -ktor = "3.0.1" +ktor = "3.1.0" sqliteJdbc = "3.45.2.0" uuid = "0.8.2" buildKonfig = "0.15.1" diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt index 846cd531..8a266636 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.powersync.PowerSyncDatabase import com.powersync.connector.supabase.SupabaseConnector +import com.powersync.sync.ConnectionMethod +import com.powersync.sync.SyncOptions import io.github.jan.supabase.auth.status.RefreshFailureCause import io.github.jan.supabase.auth.status.SessionStatus import kotlinx.coroutines.flow.MutableStateFlow @@ -44,7 +46,9 @@ internal class AuthViewModel( supabase.sessionStatus.collect { when (it) { is SessionStatus.Authenticated -> { - db.connect(supabase) + // TODO REMOVE + val options = SyncOptions(method = ConnectionMethod.WebSocket()) + db.connect(supabase, options = options) } is SessionStatus.NotAuthenticated -> { db.disconnectAndClear() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56cb50b9..1cc52cf9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,7 +86,8 @@ ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotia ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } -rsocket-client = { module = "io.rsocket.kotlin:ktor-client-rsocket", version.ref = "rsocket" } +rsocket-core = { module = "io.rsocket.kotlin:rsocket-core", version.ref = "rsocket" } +rsocket-transport-websocket = { module = "io.rsocket.kotlin:rsocket-transport-ktor-websocket-internal", version.ref = "rsocket" } sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } sqliter = { module = "co.touchlab:sqliter-driver", version.ref = "sqliter" } From 6e127f14ab95c6a1e37152cdb9ce78f4c1044e50 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 6 May 2025 16:56:07 +0200 Subject: [PATCH 05/16] Better logs for unknown requests --- .../com/powersync/PowerSyncException.kt | 2 +- .../com/powersync/bucket/BucketStorageImpl.kt | 13 +---------- .../kotlin/com/powersync/sync/Instruction.kt | 22 ++++++++++--------- .../com/powersync/sync/RSocketSupport.kt | 2 +- .../kotlin/com/powersync/sync/SyncStream.kt | 5 ++++- .../kotlin/com/powersync/demos/Main.kt | 6 +++++ 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncException.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncException.kt index 0ac7a40d..6e31d720 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncException.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncException.kt @@ -2,5 +2,5 @@ package com.powersync public class PowerSyncException( message: String, - cause: Throwable, + cause: Throwable?, ) : Exception(message, cause) diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt index 483f8a0d..f503a7dc 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt @@ -184,18 +184,7 @@ internal class BucketStorageImpl( override suspend fun control(op: String, payload: ByteArray): List { return db.writeTransaction { tx -> logger.v { "powersync_control($op, binary payload)" } - - try { - tx.get("SELECT powersync_control(?, ?) AS r", listOf(op, payload), ::handleControlResult) - } catch (e: Exception) { - println("Got control exception, writing") - SystemFileSystem.sink(Path("/Users/simon/failing_line.bin")).buffered().apply { - write(payload) - flush() - close() - } - throw e - } + tx.get("SELECT powersync_control(?, ?) AS r", listOf(op, payload), ::handleControlResult) } } } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt index 5379c4bb..e9598874 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt @@ -13,6 +13,7 @@ import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.serializer @@ -37,7 +38,7 @@ internal sealed interface Instruction { data object CloseSyncStream: Instruction data object DidCompleteSync: Instruction - data object UnknownInstruction: Instruction + data class UnknownInstruction(val raw: JsonElement?): Instruction class Serializer : KSerializer { private val logLine = serializer() @@ -68,24 +69,25 @@ internal sealed interface Instruction { 2 -> decodeSerializableElement(descriptor, 2, establishSyncStream) 3 -> decodeSerializableElement(descriptor, 3, fetchCredentials) 4 -> { - decodeSerializableElement(descriptor, 3, flushFileSystem) + decodeSerializableElement(descriptor, 4, flushFileSystem) FlushSileSystem } 5 -> { - decodeSerializableElement(descriptor, 4, closeSyncStream) + decodeSerializableElement(descriptor, 5, closeSyncStream) CloseSyncStream } 6 -> { - decodeSerializableElement(descriptor, 4, didCompleteSync) + decodeSerializableElement(descriptor, 6, didCompleteSync) DidCompleteSync } - CompositeDecoder.UNKNOWN_NAME, CompositeDecoder.DECODE_DONE -> UnknownInstruction + CompositeDecoder.UNKNOWN_NAME, -> UnknownInstruction(decodeSerializableElement(descriptor, index, serializer())) + CompositeDecoder.DECODE_DONE -> UnknownInstruction(null) else -> error("Unexpected index: $index") } if (decodeElementIndex(descriptor) != CompositeDecoder.DECODE_DONE) { // Sync lines are single-key objects, make sure there isn't another one. - UnknownInstruction + UnknownInstruction(null) } else { value } @@ -99,7 +101,7 @@ internal sealed interface Instruction { } @Serializable -internal class CoreSyncStatus( +internal data class CoreSyncStatus( val connected: Boolean, val connecting: Boolean, val downloading: CoreDownloadProgress?, @@ -108,12 +110,12 @@ internal class CoreSyncStatus( ) @Serializable -internal class CoreDownloadProgress( +internal data class CoreDownloadProgress( val buckets: Map ) @Serializable -internal class CoreBucketProgress( +internal data class CoreBucketProgress( val priority: BucketPriority, @SerialName("at_last") val atLast: Long, @@ -124,7 +126,7 @@ internal class CoreBucketProgress( ) @Serializable -internal class CorePriorityStatus( +internal data class CorePriorityStatus( val priority: BucketPriority, @SerialName("last_synced_at") @Serializable(with = InstantTimestampSerializer::class) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt b/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt index 8c473580..6f6bd300 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt @@ -83,7 +83,7 @@ internal fun HttpClient.rSocketSyncStream( val rSocket = connector.connect(target) val syncStream = rSocket.requestStream(buildPayload { - data("{}") + data(JsonUtil.json.encodeToString(req)) metadata(JsonUtil.json.encodeToString(RequestStreamMetadata("/sync/stream"))) }) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 7de3d87f..332fca6f 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -3,6 +3,7 @@ package com.powersync.sync import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.stately.concurrency.AtomicBoolean +import com.powersync.PowerSyncException import com.powersync.bucket.BucketStorage import com.powersync.bucket.WriteCheckpointResponse import com.powersync.connectors.PowerSyncBackendConnector @@ -337,7 +338,9 @@ internal class SyncStream( } is Instruction.FetchCredentials -> TODO() Instruction.DidCompleteSync -> status.update { copy(downloadError=null) } - Instruction.UnknownInstruction -> TODO() + is Instruction.UnknownInstruction -> { + throw PowerSyncException("Unknown instruction received from core extension: ${instruction.raw}", null) + } } } 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 index 483df5c0..f4dca20e 100644 --- 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 @@ -7,9 +7,15 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import co.touchlab.kermit.platformLogWriter fun main() { + Logger.setLogWriters(platformLogWriter()) + Logger.setMinSeverity(Severity.Verbose) + application { Window( onCloseRequest = ::exitApplication, From 15a7674143efd08cd608f4f46b6c4bb0d9538aff Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 6 May 2025 17:29:17 +0200 Subject: [PATCH 06/16] Test token expiry handling --- .../com/powersync/sync/SyncIntegrationTest.kt | 64 +++++++++++++++++++ .../connectors/PowerSyncBackendConnector.kt | 7 +- .../kotlin/com/powersync/sync/SyncStream.kt | 43 ++++++++++--- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index 1ee617cb..e5d86e58 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -16,11 +16,17 @@ import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest import com.powersync.testutils.waitFor import com.powersync.utils.JsonUtil +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.everySuspend import dev.mokkery.verify +import dev.mokkery.verifyNoMoreCalls +import dev.mokkery.verifySuspend import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlin.test.Test import kotlin.test.assertEquals @@ -526,4 +532,62 @@ class SyncIntegrationTest { // Meaning that the two rows are now visible database.expectUserCount(2) } + + @Test + fun testTokenExpired() = + databaseTest { + turbineScope(timeout = 10.0.seconds) { + val turbine = database.currentStatus.asFlow().testIn(this) + + database.connect(connector, 1000L, retryDelayMs = 5000) + turbine.waitFor { it.connecting } + + syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 4000)) + turbine.waitFor { it.connected } + verifySuspend { connector.getCredentialsCached() } + verifyNoMoreCalls(connector) + + // Should invalidate credentials when token expires + syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 0)) + turbine.waitFor { !it.connected } + verify { connector.invalidateCredentials() } + + turbine.cancel() + } + } + + @Test + fun testTokenPrefetch() = + databaseTest { + val prefetchCalled = CompletableDeferred() + val completePrefetch = CompletableDeferred() + every { connector.prefetchCredentials() } returns scope.launch { + prefetchCalled.complete(Unit) + completePrefetch.await() + } + + turbineScope(timeout = 10.0.seconds) { + val turbine = database.currentStatus.asFlow().testIn(this) + + database.connect(connector, 1000L, retryDelayMs = 5000) + turbine.waitFor { it.connecting } + + syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 4000)) + turbine.waitFor { it.connected } + verifySuspend { connector.getCredentialsCached() } + verifyNoMoreCalls(connector) + + syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 10)) + prefetchCalled.complete(Unit) + // Should still be connected before prefetch completes + database.currentStatus.connected shouldBe true + + // After the prefetch completes, we should reconnect + completePrefetch.complete(Unit) + turbine.waitFor { !it.connected } + + turbine.waitFor { it.connected } + turbine.cancel() + } + } } diff --git a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt index e1ce40af..a89e3b71 100644 --- a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt +++ b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt @@ -56,10 +56,10 @@ public abstract class PowerSyncBackendConnector { * This may be called before the current credentials have expired. */ @Throws(PowerSyncException::class, CancellationException::class) - public open suspend fun prefetchCredentials(): Job? { + public open fun prefetchCredentials(): Job { fetchRequest?.takeIf { it.isActive }?.let { return it } - fetchRequest = + val request = scope.launch { fetchCredentials().also { value -> cachedCredentials = value @@ -67,7 +67,8 @@ public abstract class PowerSyncBackendConnector { } } - return fetchRequest + fetchRequest = request + return request } /** diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 332fca6f..78e79658 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll @@ -256,21 +257,25 @@ internal class SyncStream( } private suspend fun streamingSyncIteration() { - val iteration = ActiveIteration() - - try { - iteration.start() - } finally { - // This can't be cancelled because we need to send a stop message, which is async, to - // clean up resources. - withContext(NonCancellable) { - iteration.stop() + coroutineScope { + val iteration = ActiveIteration(this) + + try { + iteration.start() + } finally { + // This can't be cancelled because we need to send a stop message, which is async, to + // clean up resources. + withContext(NonCancellable) { + iteration.stop() + } } } } private inner class ActiveIteration( + val scope: CoroutineScope, var fetchLinesJob: Job? = null, + var credentialsInvalidation: Job? = null, ) { suspend fun start() { control("start", JsonUtil.json.encodeToString(params)) @@ -336,7 +341,25 @@ internal class SyncStream( applyCoreChanges(instruction.status) } } - is Instruction.FetchCredentials -> TODO() + is Instruction.FetchCredentials -> { + if (instruction.didExpire) { + connector.invalidateCredentials() + } else { + // Token expires soon - refresh it in the background + if (credentialsInvalidation == null) { + val job = scope.launch { + connector.prefetchCredentials().join() + + // Token has been refreshed, start another iteration + stop() + } + job.invokeOnCompletion { + credentialsInvalidation = null + } + credentialsInvalidation = job + } + } + } Instruction.DidCompleteSync -> status.update { copy(downloadError=null) } is Instruction.UnknownInstruction -> { throw PowerSyncException("Unknown instruction received from core extension: ${instruction.raw}", null) From 90b79b1d5109f71abbc43a86079d21baca5b0d14 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 7 May 2025 11:08:33 +0200 Subject: [PATCH 07/16] Dump sync stream to file --- .../kotlin/com/powersync/sync/SyncStream.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 78e79658..25c9a4cd 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -54,6 +54,10 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.io.Sink +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject @@ -258,7 +262,9 @@ internal class SyncStream( private suspend fun streamingSyncIteration() { coroutineScope { - val iteration = ActiveIteration(this) + val file = SystemFileSystem.sink(Path("/Users/simon/test.bin")).buffered() + + val iteration = ActiveIteration(this, dumpSyncLines = file) try { iteration.start() @@ -267,6 +273,7 @@ internal class SyncStream( // clean up resources. withContext(NonCancellable) { iteration.stop() + file.close() } } } @@ -276,6 +283,7 @@ internal class SyncStream( val scope: CoroutineScope, var fetchLinesJob: Job? = null, var credentialsInvalidation: Job? = null, + var dumpSyncLines: Sink ) { suspend fun start() { control("start", JsonUtil.json.encodeToString(params)) @@ -360,7 +368,10 @@ internal class SyncStream( } } } - Instruction.DidCompleteSync -> status.update { copy(downloadError=null) } + Instruction.DidCompleteSync -> { + dumpSyncLines.flush() + status.update { copy(downloadError=null) } + } is Instruction.UnknownInstruction -> { throw PowerSyncException("Unknown instruction received from core extension: ${instruction.raw}", null) } From 5b513e5ad9f807651285f24412c1fde2cde9b561 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 7 May 2025 11:43:38 +0200 Subject: [PATCH 08/16] Remove unused imports --- .../kotlin/com/powersync/sync/SyncStream.kt | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 25c9a4cd..4fcd6235 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -17,7 +17,6 @@ import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.timeout import io.ktor.client.plugins.websocket.WebSockets -import io.ktor.client.plugins.websocket.webSocketSession import io.ktor.client.request.get import io.ktor.client.request.headers import io.ktor.client.request.preparePost @@ -26,20 +25,9 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode -import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol import io.ktor.http.contentType -import io.ktor.http.takeFrom import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readUTF8Line -import io.rsocket.kotlin.core.RSocketConnector -import io.rsocket.kotlin.payload.PayloadMimeType -import io.rsocket.kotlin.payload.buildPayload -import io.rsocket.kotlin.payload.metadata -import io.rsocket.kotlin.transport.RSocketClientTarget -import io.rsocket.kotlin.transport.RSocketConnection -import io.rsocket.kotlin.transport.RSocketTransportApi -import io.rsocket.kotlin.transport.ktor.websocket.internal.KtorWebSocketConnection import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -58,11 +46,7 @@ import kotlinx.io.Sink import kotlinx.io.buffered import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.coroutineContext internal class SyncStream( private val bucketStorage: BucketStorage, @@ -384,6 +368,7 @@ internal class SyncStream( control("line_text", rawLine) } is ConnectionMethod.WebSocket -> connectViaWebSocket(start.request, method).collect { binaryLine -> + dumpSyncLines.write(binaryLine) control("line_binary", binaryLine) } } From 803c1ede3ba3d5e3e4e35b243ec4f70d63a721fb Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 8 May 2025 14:51:33 +0200 Subject: [PATCH 09/16] Restore support for old implementation --- .../com/powersync/sync/AbstractSyncTest.kt | 12 + .../com/powersync/sync/SyncIntegrationTest.kt | 62 ++-- .../com/powersync/sync/SyncProgressTest.kt | 20 +- .../com/powersync/testutils/TestUtils.kt | 11 +- .../com/powersync/ExperimentalPowerSyncAPI.kt | 6 + .../kotlin/com/powersync/PowerSyncDatabase.kt | 2 +- .../com/powersync/bucket/BucketChecksum.kt | 2 + .../com/powersync/bucket/BucketRequest.kt | 2 + .../com/powersync/bucket/BucketStorage.kt | 17 + .../com/powersync/bucket/BucketStorageImpl.kt | 207 ++++++++++- .../kotlin/com/powersync/bucket/Checkpoint.kt | 2 + .../com/powersync/bucket/ChecksumCache.kt | 2 + .../bucket/LocalOperationCounters.kt | 3 + .../connectors/PowerSyncBackendConnector.kt | 2 +- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 25 +- .../kotlin/com/powersync/sync/Instruction.kt | 2 +- .../sync/LegacySyncImplementation.kt | 6 + .../kotlin/com/powersync/sync/Progress.kt | 38 ++ .../com/powersync/sync/RSocketSupport.kt | 3 +- .../powersync/sync/StreamingSyncRequest.kt | 1 + .../com/powersync/sync/SyncDataBatch.kt | 1 + .../kotlin/com/powersync/sync/SyncLine.kt | 2 + .../kotlin/com/powersync/sync/SyncOptions.kt | 24 +- .../kotlin/com/powersync/sync/SyncStatus.kt | 17 + .../kotlin/com/powersync/sync/SyncStream.kt | 334 ++++++++++++++++-- .../sync/StreamingSyncRequestTest.kt | 2 + .../kotlin/com/powersync/sync/SyncLineTest.kt | 1 + .../com/powersync/sync/SyncStreamTest.kt | 30 +- .../powersync/testutils/MockSyncService.kt | 2 + 29 files changed, 734 insertions(+), 104 deletions(-) create mode 100644 core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt create mode 100644 core/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt create mode 100644 core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt new file mode 100644 index 00000000..e625ff95 --- /dev/null +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt @@ -0,0 +1,12 @@ +package com.powersync.sync + +import com.powersync.ExperimentalPowerSyncAPI + +abstract class AbstractSyncTest(private val useNewSyncImplementation: Boolean) { + + @OptIn(ExperimentalPowerSyncAPI::class) + val options: SyncOptions get() { + return SyncOptions(useNewSyncImplementation) + } +} + diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index e5d86e58..d1b1ec7d 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -1,7 +1,10 @@ -package com.powersync +package com.powersync.sync import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi +import com.powersync.PowerSyncDatabase +import com.powersync.PowerSyncException +import com.powersync.TestConnector import com.powersync.bucket.BucketChecksum import com.powersync.bucket.BucketPriority import com.powersync.bucket.Checkpoint @@ -11,14 +14,12 @@ import com.powersync.bucket.WriteCheckpointData import com.powersync.bucket.WriteCheckpointResponse import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema -import com.powersync.sync.SyncLine import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest import com.powersync.testutils.waitFor import com.powersync.utils.JsonUtil import dev.mokkery.answering.returns import dev.mokkery.every -import dev.mokkery.everySuspend import dev.mokkery.verify import dev.mokkery.verifyNoMoreCalls import dev.mokkery.verifySuspend @@ -27,26 +28,27 @@ import io.kotest.matchers.shouldBe import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.seconds -class SyncIntegrationTest { +@OptIn(LegacySyncImplementation::class) +abstract class BaseSyncIntegrationTest(useNewSyncImplementation: Boolean) : AbstractSyncTest( + useNewSyncImplementation +) { private suspend fun PowerSyncDatabase.expectUserCount(amount: Int) { val users = getAll("SELECT * FROM users;") { UserRow.from(it) } users shouldHaveSize amount } @Test - @OptIn(DelicateCoroutinesApi::class) fun connectImmediately() = databaseTest(createInitialDatabase = false) { // Regression test for https://github.com/powersync-ja/powersync-kotlin/issues/169 val database = openDatabase() - database.connect(connector) + database.connect(connector, options = options) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -59,7 +61,7 @@ class SyncIntegrationTest { @OptIn(DelicateCoroutinesApi::class) fun closesResponseStreamOnDatabaseClose() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -78,7 +80,7 @@ class SyncIntegrationTest { @OptIn(DelicateCoroutinesApi::class) fun cleansResourcesOnDisconnect() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -99,7 +101,7 @@ class SyncIntegrationTest { @Test fun cannotUpdateSchemaWhileConnected() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -117,7 +119,7 @@ class SyncIntegrationTest { @Test fun testPartialSync() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) val checksums = buildList { @@ -208,7 +210,7 @@ class SyncIntegrationTest { @Test fun testRemembersLastPartialSync() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) syncLines.send( SyncLine.FullCheckpoint( @@ -244,7 +246,7 @@ class SyncIntegrationTest { @Test fun setsDownloadingState() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -278,7 +280,7 @@ class SyncIntegrationTest { turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector) + database.connect(connector, options = options) turbine.waitFor { it.connecting } database.disconnect() @@ -291,7 +293,7 @@ class SyncIntegrationTest { @Test fun testMultipleSyncsDoNotCreateMultipleStatusEntries() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -337,8 +339,8 @@ class SyncIntegrationTest { turbineScope(timeout = 10.0.seconds) { // Connect the first database - database.connect(connector) - db2.connect(connector) + database.connect(connector, options = options) + db2.connect(connector, options = options) waitFor { assertNotNull( @@ -363,10 +365,10 @@ class SyncIntegrationTest { val turbine2 = db2.currentStatus.asFlow().testIn(this) // Connect the first database - database.connect(connector) + database.connect(connector, options = options) turbine1.waitFor { it.connecting } - db2.connect(connector) + db2.connect(connector, options = options) // Should not be connecting yet db2.currentStatus.connecting shouldBe false @@ -390,13 +392,13 @@ class SyncIntegrationTest { turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, 1000L) + database.connect(connector, 1000L, options = options) turbine.waitFor { it.connecting } database.disconnect() turbine.waitFor { !it.connecting } - database.connect(connector, 1000L) + database.connect(connector, 1000L, options = options) turbine.waitFor { it.connecting } database.disconnect() turbine.waitFor { !it.connecting } @@ -411,10 +413,10 @@ class SyncIntegrationTest { turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, 1000L, retryDelayMs = 5000) + database.connect(connector, 1000L, retryDelayMs = 5000, options = options) turbine.waitFor { it.connecting } - database.connect(connector, 1000L, retryDelayMs = 5000) + database.connect(connector, 1000L, retryDelayMs = 5000, options = options) turbine.waitFor { it.connecting } turbine.cancel() @@ -427,7 +429,7 @@ class SyncIntegrationTest { databaseTest { val testConnector = TestConnector() connector = testConnector - database.connect(testConnector) + database.connect(testConnector, options = options) suspend fun expectUserRows(amount: Int) { val row = database.get("SELECT COUNT(*) FROM users") { it.getLong(0)!! } @@ -521,6 +523,7 @@ class SyncIntegrationTest { } completeUpload.complete(Unit) requestedCheckpoint.await() + logger.d { "Did request checkpoint" } // This should apply the checkpoint turbineScope { @@ -539,7 +542,7 @@ class SyncIntegrationTest { turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, 1000L, retryDelayMs = 5000) + database.connect(connector, 1000L, retryDelayMs = 5000, options = options) turbine.waitFor { it.connecting } syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 4000)) @@ -555,7 +558,14 @@ class SyncIntegrationTest { turbine.cancel() } } +} + +class LegacySyncIntegrationTest: BaseSyncIntegrationTest(false) + +class NewSyncIntegrationTest: BaseSyncIntegrationTest(true) { + // The legacy sync implementation doesn't prefetch credentials. + @OptIn(LegacySyncImplementation::class) @Test fun testTokenPrefetch() = databaseTest { @@ -569,7 +579,7 @@ class SyncIntegrationTest { turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, 1000L, retryDelayMs = 5000) + database.connect(connector, 1000L, retryDelayMs = 5000, options = options) turbine.waitFor { it.connecting } syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 4000)) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt index e7fe1d57..c98a4e86 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt @@ -18,7 +18,10 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue -class SyncProgressTest { +@OptIn(LegacySyncImplementation::class) +abstract class BaseSyncProgressTest(useNewSyncImplementation: Boolean) : AbstractSyncTest( + useNewSyncImplementation +) { private var lastOpId = 0 @BeforeTest @@ -104,7 +107,7 @@ class SyncProgressTest { @Test fun withoutPriorities() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -153,7 +156,7 @@ class SyncProgressTest { @Test fun interruptedSync() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -183,7 +186,7 @@ class SyncProgressTest { // And reconnecting database = openDatabase() syncLines = Channel() - database.connect(connector) + database.connect(connector, options = options) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -217,7 +220,7 @@ class SyncProgressTest { @Test fun interruptedSyncWithNewCheckpoint() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -243,7 +246,7 @@ class SyncProgressTest { syncLines.close() database = openDatabase() syncLines = Channel() - database.connect(connector) + database.connect(connector, options = options) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -276,7 +279,7 @@ class SyncProgressTest { @Test fun differentPriorities() = databaseTest { - database.connect(connector) + database.connect(connector, options = options) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -341,3 +344,6 @@ class SyncProgressTest { syncLines.close() } } + +class LegacySyncProgressTest: BaseSyncProgressTest(false) +class NewSyncProgressTest: BaseSyncProgressTest(true) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index 71fa3f00..be0c84a6 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -1,3 +1,5 @@ +@file:OptIn(LegacySyncImplementation::class) + package com.powersync.testutils import co.touchlab.kermit.ExperimentalKermitApi @@ -7,6 +9,7 @@ import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import co.touchlab.kermit.TestLogWriter import com.powersync.DatabaseDriverFactory +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.bucket.WriteCheckpointData import com.powersync.bucket.WriteCheckpointResponse import com.powersync.connectors.PowerSyncBackendConnector @@ -14,7 +17,9 @@ import com.powersync.connectors.PowerSyncCredentials import com.powersync.createPowerSyncDatabaseImpl import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema +import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.SyncLine +import com.powersync.sync.SyncOptions import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.mock @@ -82,6 +87,7 @@ internal class ActiveDatabaseTest( ), ) + @OptIn(LegacySyncImplementation::class) var syncLines = Channel() var checkpointResponse: () -> WriteCheckpointResponse = { WriteCheckpointResponse(WriteCheckpointData("1000")) @@ -143,10 +149,7 @@ internal class ActiveDatabaseTest( item() } - var path = databaseName - testDirectory?.let { - path = Path(it, path).name - } + val path = Path(testDirectory, databaseName).name cleanup(path) } } diff --git a/core/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt b/core/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt new file mode 100644 index 00000000..36139fc3 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt @@ -0,0 +1,6 @@ +package com.powersync + +@RequiresOptIn(message = "This API is experimental and not covered by PowerSync semver releases. It can be changed at any time") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR) +public annotation class ExperimentalPowerSyncAPI diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 769aa312..878932dc 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -95,7 +95,7 @@ public interface PowerSyncDatabase : Queries { crudThrottleMs: Long = 1000L, retryDelayMs: Long = 5000L, params: Map = emptyMap(), - options: SyncOptions = SyncOptions() + options: SyncOptions = SyncOptions.defaults ) /** diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt index 335b4429..2fe4c042 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketChecksum.kt @@ -1,8 +1,10 @@ package com.powersync.bucket +import com.powersync.sync.LegacySyncImplementation import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@LegacySyncImplementation @Serializable internal data class BucketChecksum( val bucket: String, diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt index b797842c..34a5db60 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketRequest.kt @@ -1,7 +1,9 @@ package com.powersync.bucket +import com.powersync.sync.LegacySyncImplementation import kotlinx.serialization.Serializable +@LegacySyncImplementation @Serializable internal data class BucketRequest( val name: String, diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt index 9e4146a5..ecf6df93 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt @@ -3,6 +3,7 @@ package com.powersync.bucket import com.powersync.db.crud.CrudEntry import com.powersync.db.internal.PowerSyncTransaction import com.powersync.sync.Instruction +import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.SyncDataBatch import com.powersync.sync.SyncLocalDatabaseResult @@ -28,6 +29,22 @@ internal interface BucketStorage { suspend fun hasCompletedSync(): Boolean + @LegacySyncImplementation + suspend fun getBucketStates(): List + @LegacySyncImplementation + suspend fun getBucketOperationProgress(): Map + @LegacySyncImplementation + suspend fun removeBuckets(bucketsToDelete: List) + @LegacySyncImplementation + fun setTargetCheckpoint(checkpoint: Checkpoint) + @LegacySyncImplementation + suspend fun saveSyncData(syncDataBatch: SyncDataBatch) + @LegacySyncImplementation + suspend fun syncLocalDatabase( + targetCheckpoint: Checkpoint, + partialPriority: BucketPriority? = null, + ): SyncLocalDatabaseResult + suspend fun control(op: String, payload: String?): List suspend fun control(op: String, payload: ByteArray): List } diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt index 76aa1ec1..91e53a05 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt @@ -9,14 +9,17 @@ import com.powersync.db.internal.InternalDatabase import com.powersync.db.internal.InternalTable import com.powersync.db.internal.PowerSyncTransaction import com.powersync.sync.Instruction +import com.powersync.sync.LegacySyncImplementation +import com.powersync.sync.SyncDataBatch +import com.powersync.sync.SyncLocalDatabaseResult import com.powersync.utils.JsonUtil +import kotlinx.serialization.Serializable internal class BucketStorageImpl( private val db: InternalDatabase, private val logger: Logger, ) : BucketStorage { private var hasCompletedSync = AtomicBoolean(false) - private var pendingBucketDeletes = AtomicBoolean(false) companion object { const val MAX_OP_ID = "9223372036854775807" @@ -121,6 +124,47 @@ internal class BucketStorageImpl( } } + @LegacySyncImplementation + override suspend fun saveSyncData(syncDataBatch: SyncDataBatch) { + db.writeTransaction { tx -> + val jsonString = JsonUtil.json.encodeToString(syncDataBatch) + tx.execute( + "INSERT INTO powersync_operations(op, data) VALUES(?, ?)", + listOf("save", jsonString), + ) + } + } + + @LegacySyncImplementation + override suspend fun getBucketStates(): List = + db.getAll( + "SELECT name AS bucket, CAST(last_op AS TEXT) AS op_id FROM ${InternalTable.BUCKETS} WHERE pending_delete = 0 AND name != '\$local'", + mapper = { cursor -> + BucketState( + bucket = cursor.getString(0)!!, + opId = cursor.getString(1)!!, + ) + }, + ) + + @LegacySyncImplementation + override suspend fun getBucketOperationProgress(): Map = + buildMap { + val rows = + db.getAll("SELECT name, count_at_last, count_since_last FROM ps_buckets") { cursor -> + cursor.getString(0)!! to + LocalOperationCounters( + atLast = cursor.getLong(1)!!.toInt(), + sinceLast = cursor.getLong(2)!!.toInt(), + ) + } + + for ((name, counters) in rows) { + put(name, counters) + } + } + + @LegacySyncImplementation private suspend fun deleteBucket(bucketName: String) { db.writeTransaction { tx -> tx.execute( @@ -130,8 +174,13 @@ internal class BucketStorageImpl( } Logger.d("[deleteBucket] Done deleting") + } - this.pendingBucketDeletes.value = true + @LegacySyncImplementation + override suspend fun removeBuckets(bucketsToDelete: List) { + bucketsToDelete.forEach { bucketName -> + deleteBucket(bucketName) + } } override suspend fun hasCompletedSync(): Boolean { @@ -155,6 +204,160 @@ internal class BucketStorageImpl( } } + @LegacySyncImplementation + override suspend fun syncLocalDatabase( + targetCheckpoint: Checkpoint, + partialPriority: BucketPriority?, + ): SyncLocalDatabaseResult { + val result = validateChecksums(targetCheckpoint, partialPriority) + + if (!result.checkpointValid) { + logger.w { "[SyncLocalDatabase] Checksums failed for ${result.checkpointFailures}" } + result.checkpointFailures?.forEach { bucketName -> + deleteBucket(bucketName) + } + result.ready = false + return result + } + + val bucketNames = + targetCheckpoint.checksums + .let { + if (partialPriority == null) { + it + } else { + it.filter { cs -> cs.priority >= partialPriority } + } + }.map { it.bucket } + + db.writeTransaction { tx -> + tx.execute( + "UPDATE ps_buckets SET last_op = ? WHERE name IN (SELECT json_each.value FROM json_each(?))", + listOf(targetCheckpoint.lastOpId, JsonUtil.json.encodeToString(bucketNames)), + ) + + if (partialPriority == null && targetCheckpoint.writeCheckpoint != null) { + tx.execute( + "UPDATE ps_buckets SET last_op = ? WHERE name = '\$local'", + listOf(targetCheckpoint.writeCheckpoint), + ) + } + } + + val valid = updateObjectsFromBuckets(targetCheckpoint, partialPriority) + + if (!valid) { + return SyncLocalDatabaseResult( + ready = false, + checkpointValid = true, + ) + } + + return SyncLocalDatabaseResult( + ready = true, + ) + } + + @LegacySyncImplementation + private suspend fun validateChecksums( + checkpoint: Checkpoint, + priority: BucketPriority? = null, + ): SyncLocalDatabaseResult { + val serializedCheckpoint = + JsonUtil.json.encodeToString( + when (priority) { + null -> checkpoint + // Only validate buckets with a priority included in this partial sync. + else -> checkpoint.copy(checksums = checkpoint.checksums.filter { it.priority >= priority }) + }, + ) + + val res = + db.getOptional( + "SELECT powersync_validate_checkpoint(?) AS result", + parameters = listOf(serializedCheckpoint), + mapper = { cursor -> + cursor.getString(0)!! + }, + ) + ?: // no result + return SyncLocalDatabaseResult( + ready = false, + checkpointValid = false, + ) + + return JsonUtil.json.decodeFromString(res) + } + + /** + * Atomically update the local state. + * + * This includes creating new tables, dropping old tables, and copying data over from the oplog. + */ + @LegacySyncImplementation + private suspend fun updateObjectsFromBuckets( + checkpoint: Checkpoint, + priority: BucketPriority? = null, + ): Boolean { + @Serializable + data class SyncLocalArgs( + val priority: BucketPriority, + val buckets: List, + ) + + val args = + if (priority != null) { + JsonUtil.json.encodeToString( + SyncLocalArgs( + priority = priority, + buckets = checkpoint.checksums.filter { it.priority >= priority }.map { it.bucket }, + ), + ) + } else { + "" + } + + return db.writeTransaction { tx -> + tx.execute( + "INSERT INTO powersync_operations(op, data) VALUES(?, ?)", + listOf("sync_local", args), + ) + + val res = + tx.get("select last_insert_rowid()") { cursor -> + cursor.getLong(0)!! + } + + val didApply = res == 1L + if (didApply && priority == null) { + // Reset progress counters. We only do this for a complete sync, as we want a download progress to + // always cover a complete checkpoint instead of resetting for partial completions. + tx.execute( + """ + UPDATE ps_buckets SET count_since_last = 0, count_at_last = ?1->name + WHERE name != '${'$'}local' AND ?1->name IS NOT NULL + """.trimIndent(), + listOf( + JsonUtil.json.encodeToString( + buildMap { + for (bucket in checkpoint.checksums) { + bucket.count?.let { put(bucket.bucket, it) } + } + }, + ), + ), + ) + } + + return@writeTransaction didApply + } + } + + @LegacySyncImplementation + override fun setTargetCheckpoint(checkpoint: Checkpoint) { + // No-op + } + private fun handleControlResult(cursor: SqlCursor): List { val result = cursor.getString(0)!! logger.v { "control result: $result" } diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt b/core/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt index f0088dbf..5dc4823b 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/Checkpoint.kt @@ -1,8 +1,10 @@ package com.powersync.bucket +import com.powersync.sync.LegacySyncImplementation import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@LegacySyncImplementation @Serializable internal data class Checkpoint( @SerialName("last_op_id") val lastOpId: String, diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt b/core/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt index bd1ce5c6..44faa005 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/ChecksumCache.kt @@ -1,8 +1,10 @@ package com.powersync.bucket +import com.powersync.sync.LegacySyncImplementation import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@LegacySyncImplementation @Serializable internal data class ChecksumCache( @SerialName("last_op_id") val lostOpId: String, diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/LocalOperationCounters.kt b/core/src/commonMain/kotlin/com/powersync/bucket/LocalOperationCounters.kt index 73f6a6fc..b18ae227 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/LocalOperationCounters.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/LocalOperationCounters.kt @@ -1,5 +1,8 @@ package com.powersync.bucket +import com.powersync.sync.LegacySyncImplementation + +@LegacySyncImplementation internal data class LocalOperationCounters( val atLast: Int, val sinceLast: Int, diff --git a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt index a89e3b71..be919e49 100644 --- a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt +++ b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt @@ -33,7 +33,7 @@ public abstract class PowerSyncBackendConnector { public open suspend fun getCredentialsCached(): PowerSyncCredentials? { return runWrappedSuspending { cachedCredentials?.let { return@runWrappedSuspending it } - prefetchCredentials()?.join() + prefetchCredentials().join() cachedCredentials } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 2d010ee1..08e19bce 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -2,6 +2,7 @@ package com.powersync.db import co.touchlab.kermit.Logger import com.powersync.DatabaseDriverFactory +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase import com.powersync.PowerSyncException import com.powersync.bucket.BucketPriority @@ -30,7 +31,6 @@ import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelAndJoin @@ -47,7 +47,6 @@ import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant -import kotlinx.serialization.encodeToString import kotlin.time.Duration.Companion.milliseconds /** @@ -160,7 +159,7 @@ internal class PowerSyncDatabaseImpl( mutex.withLock { disconnectInternal() - connectInternal( + connectInternal(crudThrottleMs) { scope -> SyncStream( bucketStorage = bucketStorage, connector = connector, @@ -168,23 +167,29 @@ internal class PowerSyncDatabaseImpl( retryDelayMs = retryDelayMs, logger = logger, params = params.toJsonObject(), - scope = scope, + uploadScope = scope, createClient = createClient, options = options, - ), - crudThrottleMs, - ) + ) + } } } - internal fun connectInternal( - stream: SyncStream, + private fun connectInternal( crudThrottleMs: Long, + createStream: (CoroutineScope) -> SyncStream, ) { val db = this val job = SupervisorJob(scope.coroutineContext[Job]) syncSupervisorJob = job + var activeStream: SyncStream? = null + scope.launch(job) { + // Create the stream in this scope so that everything launched by the stream is bound to + // this coroutine scope that can be cancelled independently. + val stream = createStream(this) + activeStream = stream + launch { // Get a global lock for checking mutex maps val streamMutex = resource.group.syncMutex @@ -236,7 +241,7 @@ internal class PowerSyncDatabaseImpl( job.invokeOnCompletion { if (it is DisconnectRequestedException) { - stream.invalidateCredentials() + activeStream?.invalidateCredentials() } } } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt index e9598874..f7ee9b10 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt @@ -50,7 +50,7 @@ internal sealed interface Instruction { private val didCompleteSync = serializer() override val descriptor = - buildClassSerialDescriptor(SyncLine::class.qualifiedName!!) { + buildClassSerialDescriptor(Instruction::class.qualifiedName!!) { element("LogLine", logLine.descriptor, isOptional = true) element("UpdateSyncStatus", updateSyncStatus.descriptor, isOptional = true) element("EstablishSyncStream", establishSyncStream.descriptor, isOptional = true) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt b/core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt new file mode 100644 index 00000000..fbd9f71c --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt @@ -0,0 +1,6 @@ +package com.powersync.sync + +@RequiresOptIn(message = "Marker class for the old Kotlin-based sync implementation, making it easier to recognize classes we can remove after switching to the Rust sync implementation.") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR) +internal annotation class LegacySyncImplementation diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt index 74e834a3..ae32f83d 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt @@ -1,6 +1,8 @@ package com.powersync.sync import com.powersync.bucket.BucketPriority +import com.powersync.bucket.Checkpoint +import com.powersync.bucket.LocalOperationCounters /** * Information about a progressing download. @@ -76,6 +78,42 @@ public data class SyncDownloadProgress internal constructor( downloadedOperations = completed } + @LegacySyncImplementation + internal constructor(localProgress: Map, target: Checkpoint): this( + buildMap { + for (entry in target.checksums) { + val savedProgress = localProgress[entry.bucket] + + put( + entry.bucket, + CoreBucketProgress( + priority = entry.priority, + atLast = (savedProgress?.atLast ?: 0).toLong(), + sinceLast = (savedProgress?.sinceLast ?: 0).toLong(), + targetCount = (entry.count ?: 0).toLong(), + ), + ) + } + }) + + @LegacySyncImplementation + internal fun incrementDownloaded(batch: SyncDataBatch): SyncDownloadProgress = + SyncDownloadProgress( + buildMap { + putAll(this@SyncDownloadProgress.buckets) + + for (bucket in batch.buckets) { + val previous = get(bucket.bucket) ?: continue + put( + bucket.bucket, + previous.copy( + sinceLast = previous.sinceLast + bucket.data.size, + ), + ) + } + }, + ) + /** * Returns download progress towards all data up until the specified [priority] being received. * diff --git a/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt b/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt index 6f6bd300..a5dfe548 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt @@ -1,5 +1,6 @@ package com.powersync.sync +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.connectors.PowerSyncCredentials import com.powersync.utils.JsonUtil import io.ktor.client.HttpClient @@ -30,7 +31,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject import kotlin.coroutines.CoroutineContext -@OptIn(RSocketTransportApi::class) +@OptIn(RSocketTransportApi::class, ExperimentalPowerSyncAPI::class) internal fun HttpClient.rSocketSyncStream( options: ConnectionMethod.WebSocket, req: JsonObject, diff --git a/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt b/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt index 9c77ef8d..a09c15f4 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject +@LegacySyncImplementation @Serializable internal data class StreamingSyncRequest( val buckets: List, diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt index 65efdb43..133aee21 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncDataBatch.kt @@ -2,6 +2,7 @@ package com.powersync.sync import kotlinx.serialization.Serializable +@LegacySyncImplementation @Serializable internal data class SyncDataBatch( val buckets: List, diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncLine.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncLine.kt index 242542cd..a9a9a4f3 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncLine.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncLine.kt @@ -16,6 +16,7 @@ import kotlinx.serialization.encoding.decodeStructure import kotlinx.serialization.encoding.encodeStructure import kotlinx.serialization.serializer +@LegacySyncImplementation @Serializable(with = SyncLineSerializer::class) internal sealed interface SyncLine { data class FullCheckpoint( @@ -57,6 +58,7 @@ internal sealed interface SyncLine { data object UnknownSyncLine : SyncLine } +@LegacySyncImplementation private class SyncLineSerializer : KSerializer { private val checkpoint = serializer() private val checkpointDiff = serializer() diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index 0429dc3a..6dacb5e1 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -1,15 +1,32 @@ package com.powersync.sync +import com.powersync.PowerSyncDatabase +import com.powersync.ExperimentalPowerSyncAPI import io.rsocket.kotlin.keepalive.KeepAlive import kotlin.time.Duration.Companion.seconds -public class SyncOptions( +/** + * Experimental options that can be passed to [PowerSyncDatabase.connect] to specify an experimental + * connection mechanism. + * + * The new connection implementation is more efficient and we expect it to become the default in + * the future. At the moment, the implementation is not covered by the stability guarantees we offer + * for the rest of the SDK though. + */ +public class SyncOptions @ExperimentalPowerSyncAPI constructor( + public val newClientImplementation: Boolean = false, public val method: ConnectionMethod = ConnectionMethod.Http, -) +) { + internal companion object { + @OptIn(ExperimentalPowerSyncAPI::class) + val defaults: SyncOptions = SyncOptions() + } +} /** * The connection method to use when the SDK connects to the sync service. */ +@ExperimentalPowerSyncAPI public sealed interface ConnectionMethod { /** * Receive sync lines via streamed HTTP response from the sync service. @@ -19,13 +36,16 @@ public sealed interface ConnectionMethod { * * This is currently the default, but this will be changed once [WebSocket] support is stable. */ + @ExperimentalPowerSyncAPI public data object Http: ConnectionMethod /** * Receive binary sync lines via RSocket over a WebSocket connection. * * This connection mode is currently experimental and requires a recent sync service to work. + * WebSocket support is only available when enabling the [SyncOptions.newClientImplementation]. */ + @ExperimentalPowerSyncAPI public data class WebSocket( val keepAlive: KeepAlive = DefaultKeepAlive ): ConnectionMethod { diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt index 1471d387..23af64db 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt @@ -144,6 +144,23 @@ internal data class SyncStatusDataContainer( ) } ) } + + @LegacySyncImplementation + internal fun abortedDownload() = + copy( + downloading = false, + downloadProgress = null, + ) + + @LegacySyncImplementation + internal fun copyWithCompletedDownload() = + copy( + lastSyncedAt = Clock.System.now(), + downloading = false, + downloadProgress = null, + hasSynced = true, + downloadError = null, + ) } @ConsistentCopyVisibility diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index b20e1fbe..bc6c5d95 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -3,8 +3,13 @@ package com.powersync.sync import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.stately.concurrency.AtomicBoolean +import co.touchlab.stately.concurrency.AtomicReference +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException +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 com.powersync.connectors.PowerSyncBackendConnector import com.powersync.db.crud.CrudEntry @@ -29,6 +34,7 @@ import io.ktor.http.contentType import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readUTF8Line import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable @@ -42,12 +48,16 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock import kotlinx.io.Sink import kotlinx.io.buffered import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +@OptIn(ExperimentalPowerSyncAPI::class) internal class SyncStream( private val bucketStorage: BucketStorage, private val connector: PowerSyncBackendConnector, @@ -55,12 +65,12 @@ internal class SyncStream( private val retryDelayMs: Long = 5000L, private val logger: Logger, private val params: JsonObject, - private val scope: CoroutineScope, + private val uploadScope: CoroutineScope, private val options: SyncOptions, createClient: (HttpClientConfig<*>.() -> Unit) -> HttpClient, ) { - private var isUploadingCrud = AtomicBoolean(false) - private val completedCrudUploads = Channel(onBufferOverflow = BufferOverflow.DROP_OLDEST) + private var isUploadingCrud = AtomicReference(null) + private var completedCrudUploads = Channel(onBufferOverflow = BufferOverflow.DROP_OLDEST) /** * The current sync status. This instance is mutated as changes occur @@ -114,16 +124,24 @@ internal class SyncStream( } fun triggerCrudUploadAsync(): Job = - scope.launch { - if (!status.connected || !isUploadingCrud.compareAndSet(expected = false, new = true)) { - return@launch - } + uploadScope.launch { + val thisIteration = PendingCrudUpload(CompletableDeferred()) + var holdingUploadLock = false try { + if (!status.connected || !isUploadingCrud.compareAndSet(null, thisIteration)) { + return@launch + } + + holdingUploadLock = true uploadAllCrud() - completedCrudUploads.send(Unit) } finally { - isUploadingCrud.value = false + if (holdingUploadLock) { + completedCrudUploads.send(Unit) + isUploadingCrud.set(null) + } + + thisIteration.done.complete(Unit) } } @@ -192,7 +210,7 @@ internal class SyncStream( return body.data.writeCheckpoint } - private fun connectViaHttp(req: JsonObject): Flow = + private fun connectViaHttp(req: JsonElement): Flow = flow { val credentials = connector.getCredentialsCached() require(credentials != null) { "Not logged in" } @@ -244,28 +262,40 @@ internal class SyncStream( private suspend fun streamingSyncIteration() { coroutineScope { - val file = SystemFileSystem.sink(Path("/Users/simon/test.bin")).buffered() - - val iteration = ActiveIteration(this, dumpSyncLines = file) - - try { - iteration.start() - } finally { - // This can't be cancelled because we need to send a stop message, which is async, to - // clean up resources. - withContext(NonCancellable) { - iteration.stop() - file.close() + if (options.newClientImplementation) { + val iteration = ActiveIteration(this) + + try { + iteration.start() + } finally { + // This can't be cancelled because we need to send a stop message, which is async, to + // clean up resources. + withContext(NonCancellable) { + iteration.stop() + } } + } else { + legacySyncIteration() } } } + @OptIn(LegacySyncImplementation::class) + private suspend fun CoroutineScope.legacySyncIteration() { + LegacyIteration(this).streamingSyncIteration() + } + + /** + * Implementation of a sync iteration that delegates to helper functions implemented in the + * Rust core extension. + * + * This avoids us having to decode sync lines in Kotlin, unlocking the RSocket protocol and + * improving performance. + */ private inner class ActiveIteration( val scope: CoroutineScope, var fetchLinesJob: Job? = null, var credentialsInvalidation: Job? = null, - var dumpSyncLines: Sink ) { suspend fun start() { control("start", JsonUtil.json.encodeToString(params)) @@ -351,7 +381,6 @@ internal class SyncStream( } } Instruction.DidCompleteSync -> { - dumpSyncLines.flush() status.update { copy(downloadError=null) } } is Instruction.UnknownInstruction -> { @@ -366,13 +395,255 @@ internal class SyncStream( control("line_text", rawLine) } is ConnectionMethod.WebSocket -> connectViaWebSocket(start.request, method).collect { binaryLine -> - dumpSyncLines.write(binaryLine) control("line_binary", binaryLine) } } } } + @LegacySyncImplementation + private inner class LegacyIteration(val scope: CoroutineScope) { + suspend fun streamingSyncIteration() { + val bucketEntries = bucketStorage.getBucketStates() + val initialBuckets = mutableMapOf() + + var state = + SyncStreamState( + targetCheckpoint = null, + validatedCheckpoint = null, + appliedCheckpoint = null, + bucketSet = initialBuckets.keys.toMutableSet(), + ) + + bucketEntries.forEach { entry -> + initialBuckets[entry.bucket] = entry.opId + } + + val req = + StreamingSyncRequest( + buckets = initialBuckets.map { (bucket, after) -> BucketRequest(bucket, after) }, + clientId = clientId!!, + parameters = params, + ) + + lateinit var receiveLines: Job + receiveLines = scope.launch { + connectViaHttp(JsonUtil.json.encodeToJsonElement(req)).collect { value -> + val line = JsonUtil.json.decodeFromString(value) + + state = handleInstruction(line, value, state) + + if (state.abortIteration) { + receiveLines.cancel() + } + } + } + + receiveLines.join() + status.update { abortedDownload() } + } + + private suspend fun handleInstruction( + line: SyncLine, + jsonString: String, + state: SyncStreamState, + ): SyncStreamState = + when (line) { + is SyncLine.FullCheckpoint -> handleStreamingSyncCheckpoint(line, state) + is SyncLine.CheckpointDiff -> handleStreamingSyncCheckpointDiff(line, state) + is SyncLine.CheckpointComplete -> handleStreamingSyncCheckpointComplete(state) + is SyncLine.CheckpointPartiallyComplete -> + handleStreamingSyncCheckpointPartiallyComplete( + line, + state, + ) + + is SyncLine.KeepAlive -> handleStreamingKeepAlive(line, state) + is SyncLine.SyncDataBucket -> handleStreamingSyncData(line, state) + SyncLine.UnknownSyncLine -> { + logger.w { "Unhandled instruction $jsonString" } + state + } + } + + private suspend fun handleStreamingSyncCheckpoint( + line: SyncLine.FullCheckpoint, + state: SyncStreamState, + ): SyncStreamState { + val (checkpoint) = line + state.targetCheckpoint = checkpoint + + val bucketsToDelete = state.bucketSet!!.toMutableList() + val newBuckets = mutableSetOf() + + checkpoint.checksums.forEach { checksum -> + run { + newBuckets.add(checksum.bucket) + bucketsToDelete.remove(checksum.bucket) + } + } + + state.bucketSet = newBuckets + startTrackingCheckpoint(checkpoint, bucketsToDelete) + + return state + } + + private suspend fun startTrackingCheckpoint( + checkpoint: Checkpoint, + bucketsToDelete: List, + ) { + val progress = bucketStorage.getBucketOperationProgress() + status.update { + copy( + downloading = true, + downloadProgress = SyncDownloadProgress(progress, checkpoint), + ) + } + + if (bucketsToDelete.isNotEmpty()) { + logger.i { "Removing buckets [${bucketsToDelete.joinToString(separator = ", ")}]" } + } + + bucketStorage.removeBuckets(bucketsToDelete) + bucketStorage.setTargetCheckpoint(checkpoint) + } + + private suspend fun handleStreamingSyncCheckpointComplete(state: SyncStreamState): SyncStreamState { + val checkpoint = state.targetCheckpoint!! + var result = bucketStorage.syncLocalDatabase(checkpoint) + val pending = isUploadingCrud.get() + + if (!result.checkpointValid) { + // This means checksums failed. Start again with a new checkpoint. + // TODO: better back-off + delay(50) + state.abortIteration = true + // TODO handle retries + return state + } else if (!result.ready && pending != null) { + // We have pending entries in the local upload queue or are waiting to confirm a write checkpoint, which + // prevented this checkpoint from applying. Wait for that to complete and try again. + logger.d { "Could not apply checkpoint due to local data. Waiting for in-progress upload before retrying." } + pending.done.await() + + result = bucketStorage.syncLocalDatabase(checkpoint) + } + + if (result.checkpointValid && result.ready) { + state.appliedCheckpoint = checkpoint.clone() + logger.i { "validated checkpoint ${state.appliedCheckpoint}" } + + state.validatedCheckpoint = state.targetCheckpoint + status.update { copyWithCompletedDownload() } + } else { + logger.d { "Could not apply checkpoint. Waiting for next sync complete line" } + } + + return state + } + + private suspend fun handleStreamingSyncCheckpointPartiallyComplete( + line: SyncLine.CheckpointPartiallyComplete, + state: SyncStreamState, + ): SyncStreamState { + val priority = line.priority + val result = bucketStorage.syncLocalDatabase(state.targetCheckpoint!!, priority) + if (!result.checkpointValid) { + // This means checksums failed. Start again with a new checkpoint. + // TODO: better back-off + delay(50) + state.abortIteration = true + // TODO handle retries + return state + } else if (!result.ready) { + // Checkpoint is valid, but we have local data preventing this to be published. We'll try to resolve this + // once we have a complete checkpoint if the problem persists. + } else { + logger.i { "validated partial checkpoint ${state.appliedCheckpoint} up to priority of $priority" } + } + + status.update { + copy( + priorityStatusEntries = + buildList { + // All states with a higher priority can be deleted since this partial sync includes them. + addAll(status.priorityStatusEntries.filter { it.priority >= line.priority }) + add( + PriorityStatusEntry( + priority = priority, + lastSyncedAt = Clock.System.now(), + hasSynced = true, + ), + ) + }, + ) + } + return state + } + + private suspend fun handleStreamingSyncCheckpointDiff( + checkpointDiff: SyncLine.CheckpointDiff, + 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 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 + startTrackingCheckpoint(newCheckpoint, checkpointDiff.removedBuckets) + + return state + } + + private suspend fun handleStreamingSyncData( + data: SyncLine.SyncDataBucket, + state: SyncStreamState, + ): SyncStreamState { + val batch = SyncDataBatch(listOf(data)) + status.update { copy(downloading = true, downloadProgress = downloadProgress?.incrementDownloaded(batch)) } + bucketStorage.saveSyncData(batch) + return state + } + + private fun handleStreamingKeepAlive( + keepAlive: SyncLine.KeepAlive, + state: SyncStreamState, + ): SyncStreamState { + val (tokenExpiresIn) = keepAlive + + if (tokenExpiresIn <= 0) { + // Connection would be closed automatically right after this + logger.i { "Token expiring reconnect" } + connector.invalidateCredentials() + state.abortIteration = true + return state + } + // Don't await the upload job, we can keep receiving sync lines + triggerCrudUploadAsync() + return state + } + } + internal companion object { fun defaultHttpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient { @@ -380,3 +651,16 @@ internal class SyncStream( } } } + +@LegacySyncImplementation +internal data class SyncStreamState( + var targetCheckpoint: Checkpoint?, + var validatedCheckpoint: Checkpoint?, + var appliedCheckpoint: Checkpoint?, + var bucketSet: MutableSet?, + var abortIteration: Boolean = false, +) + +private class PendingCrudUpload( + val done: CompletableDeferred, +) diff --git a/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncRequestTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncRequestTest.kt index 0d9fb85d..e31afa63 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncRequestTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/StreamingSyncRequestTest.kt @@ -1,4 +1,5 @@ import com.powersync.bucket.BucketRequest +import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.StreamingSyncRequest import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -9,6 +10,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +@OptIn(LegacySyncImplementation::class) class StreamingSyncRequestTest { private val json = Json { ignoreUnknownKeys = true } diff --git a/core/src/commonTest/kotlin/com/powersync/sync/SyncLineTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/SyncLineTest.kt index 50a88cd9..5236d1e0 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncLineTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncLineTest.kt @@ -7,6 +7,7 @@ import com.powersync.utils.JsonUtil import kotlin.test.Test import kotlin.test.assertEquals +@OptIn(LegacySyncImplementation::class) class SyncLineTest { private fun checkDeserializing( expected: SyncLine, diff --git a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt index c4b12301..f0447692 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -1,51 +1,33 @@ package com.powersync.sync -import app.cash.turbine.turbineScope +import co.touchlab.kermit.ExperimentalKermitApi import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import co.touchlab.kermit.TestLogWriter -import com.powersync.bucket.BucketChecksum -import com.powersync.bucket.BucketPriority +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.bucket.BucketStorage -import com.powersync.bucket.Checkpoint -import com.powersync.bucket.OpType -import com.powersync.bucket.OplogEntry -import com.powersync.bucket.WriteCheckpointData -import com.powersync.bucket.WriteCheckpointResponse import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.connectors.PowerSyncCredentials import com.powersync.db.crud.CrudEntry import com.powersync.db.crud.UpdateType -import com.powersync.testutils.MockSyncService -import com.powersync.testutils.waitFor -import com.powersync.utils.JsonUtil import dev.mokkery.answering.returns import dev.mokkery.everySuspend -import dev.mokkery.matcher.any import dev.mokkery.mock -import dev.mokkery.resetCalls import dev.mokkery.verify -import dev.mokkery.verify.VerifyMode.Companion.order -import dev.mokkery.verifyNoMoreCalls -import dev.mokkery.verifySuspend import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.seconds -@OptIn(co.touchlab.kermit.ExperimentalKermitApi::class) +@OptIn(ExperimentalKermitApi::class, ExperimentalPowerSyncAPI::class) class SyncStreamTest { private lateinit var bucketStorage: BucketStorage private lateinit var connector: PowerSyncBackendConnector @@ -98,7 +80,7 @@ class SyncStreamTest { uploadCrud = {}, logger = logger, params = JsonObject(emptyMap()), - scope = this, + uploadScope = this, options = SyncOptions(), ) @@ -136,7 +118,7 @@ class SyncStreamTest { retryDelayMs = 10, logger = logger, params = JsonObject(emptyMap()), - scope = this, + uploadScope = this, options = SyncOptions(), ) @@ -176,7 +158,7 @@ class SyncStreamTest { retryDelayMs = 10, logger = logger, params = JsonObject(emptyMap()), - scope = this, + uploadScope = this, options = SyncOptions() ) diff --git a/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt b/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt index 42831a11..591750dc 100644 --- a/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt +++ b/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt @@ -2,6 +2,7 @@ package com.powersync.testutils import app.cash.turbine.ReceiveTurbine import com.powersync.bucket.WriteCheckpointResponse +import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.SyncLine import com.powersync.sync.SyncStatusData import com.powersync.utils.JsonUtil @@ -32,6 +33,7 @@ import kotlinx.serialization.encodeToString * function which makes it very hard to cancel the channel when the sync client closes the request stream. That is * precisely what we may want to test though. */ +@OptIn(LegacySyncImplementation::class) internal class MockSyncService( private val lines: ReceiveChannel, private val generateCheckpoint: () -> WriteCheckpointResponse, From 9b3fd051ea6c2819cd4614b2d99b16e214cb3cbd Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 8 May 2025 15:04:42 +0200 Subject: [PATCH 10/16] Make public --- core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index 6dacb5e1..564e067e 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -17,9 +17,9 @@ public class SyncOptions @ExperimentalPowerSyncAPI constructor( public val newClientImplementation: Boolean = false, public val method: ConnectionMethod = ConnectionMethod.Http, ) { - internal companion object { + public companion object { @OptIn(ExperimentalPowerSyncAPI::class) - val defaults: SyncOptions = SyncOptions() + public val defaults: SyncOptions = SyncOptions() } } From 90714b5c292757e60ea8e27c4c05bfb08a79f8ce Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 8 May 2025 15:20:45 +0200 Subject: [PATCH 11/16] Reformat --- .../com/powersync/sync/AbstractSyncTest.kt | 10 +- .../com/powersync/sync/SyncIntegrationTest.kt | 21 ++-- .../com/powersync/sync/SyncProgressTest.kt | 13 +- .../com/powersync/testutils/TestUtils.kt | 2 - .../com/powersync/ExperimentalPowerSyncAPI.kt | 8 +- .../kotlin/com/powersync/PowerSyncDatabase.kt | 2 +- .../com/powersync/bucket/BucketStorage.kt | 16 ++- .../com/powersync/bucket/BucketStorageImpl.kt | 24 ++-- .../connectors/PowerSyncBackendConnector.kt | 2 +- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 3 +- .../kotlin/com/powersync/sync/Instruction.kt | 57 ++++++--- .../sync/LegacySyncImplementation.kt | 6 +- .../kotlin/com/powersync/sync/Progress.kt | 33 +++-- .../com/powersync/sync/RSocketSupport.kt | 111 ++++++++++------- .../kotlin/com/powersync/sync/SyncOptions.kt | 37 +++--- .../kotlin/com/powersync/sync/SyncStatus.kt | 13 +- .../kotlin/com/powersync/sync/SyncStream.kt | 113 ++++++++++-------- .../com/powersync/bucket/BucketStorageTest.kt | 1 - .../com/powersync/sync/SyncStreamTest.kt | 2 +- .../powersync/DatabaseDriverFactory.jvm.kt | 1 + 20 files changed, 285 insertions(+), 190 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt index e625ff95..6e54618a 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt @@ -2,11 +2,15 @@ package com.powersync.sync import com.powersync.ExperimentalPowerSyncAPI -abstract class AbstractSyncTest(private val useNewSyncImplementation: Boolean) { - +/** + * Small utility to run tests both with the legacy Kotlin sync implementation and the new + * implementation from the core extension. + */ +abstract class AbstractSyncTest( + private val useNewSyncImplementation: Boolean, +) { @OptIn(ExperimentalPowerSyncAPI::class) val options: SyncOptions get() { return SyncOptions(useNewSyncImplementation) } } - diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index d1b1ec7d..de823ca8 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -35,9 +35,11 @@ import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.seconds @OptIn(LegacySyncImplementation::class) -abstract class BaseSyncIntegrationTest(useNewSyncImplementation: Boolean) : AbstractSyncTest( - useNewSyncImplementation -) { +abstract class BaseSyncIntegrationTest( + useNewSyncImplementation: Boolean, +) : AbstractSyncTest( + useNewSyncImplementation, + ) { private suspend fun PowerSyncDatabase.expectUserCount(amount: Int) { val users = getAll("SELECT * FROM users;") { UserRow.from(it) } users shouldHaveSize amount @@ -560,9 +562,9 @@ abstract class BaseSyncIntegrationTest(useNewSyncImplementation: Boolean) : Abst } } -class LegacySyncIntegrationTest: BaseSyncIntegrationTest(false) +class LegacySyncIntegrationTest : BaseSyncIntegrationTest(false) -class NewSyncIntegrationTest: BaseSyncIntegrationTest(true) { +class NewSyncIntegrationTest : BaseSyncIntegrationTest(true) { // The legacy sync implementation doesn't prefetch credentials. @OptIn(LegacySyncImplementation::class) @@ -571,10 +573,11 @@ class NewSyncIntegrationTest: BaseSyncIntegrationTest(true) { databaseTest { val prefetchCalled = CompletableDeferred() val completePrefetch = CompletableDeferred() - every { connector.prefetchCredentials() } returns scope.launch { - prefetchCalled.complete(Unit) - completePrefetch.await() - } + every { connector.prefetchCredentials() } returns + scope.launch { + prefetchCalled.complete(Unit) + completePrefetch.await() + } turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt index c98a4e86..93739e3e 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt @@ -19,9 +19,11 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(LegacySyncImplementation::class) -abstract class BaseSyncProgressTest(useNewSyncImplementation: Boolean) : AbstractSyncTest( - useNewSyncImplementation -) { +abstract class BaseSyncProgressTest( + useNewSyncImplementation: Boolean, +) : AbstractSyncTest( + useNewSyncImplementation, + ) { private var lastOpId = 0 @BeforeTest @@ -345,5 +347,6 @@ abstract class BaseSyncProgressTest(useNewSyncImplementation: Boolean) : Abstrac } } -class LegacySyncProgressTest: BaseSyncProgressTest(false) -class NewSyncProgressTest: BaseSyncProgressTest(true) +class LegacySyncProgressTest : BaseSyncProgressTest(false) + +class NewSyncProgressTest : BaseSyncProgressTest(true) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index be0c84a6..f55f2440 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -9,7 +9,6 @@ import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import co.touchlab.kermit.TestLogWriter import com.powersync.DatabaseDriverFactory -import com.powersync.ExperimentalPowerSyncAPI import com.powersync.bucket.WriteCheckpointData import com.powersync.bucket.WriteCheckpointResponse import com.powersync.connectors.PowerSyncBackendConnector @@ -19,7 +18,6 @@ import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.SyncLine -import com.powersync.sync.SyncOptions import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.mock diff --git a/core/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt b/core/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt index 36139fc3..8ec3c06b 100644 --- a/core/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt +++ b/core/src/commonMain/kotlin/com/powersync/ExperimentalPowerSyncAPI.kt @@ -2,5 +2,11 @@ package com.powersync @RequiresOptIn(message = "This API is experimental and not covered by PowerSync semver releases. It can be changed at any time") @Retention(AnnotationRetention.BINARY) -@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.PROPERTY, + AnnotationTarget.VALUE_PARAMETER, +) public annotation class ExperimentalPowerSyncAPI diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 878932dc..d587932d 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -95,7 +95,7 @@ public interface PowerSyncDatabase : Queries { crudThrottleMs: Long = 1000L, retryDelayMs: Long = 5000L, params: Map = emptyMap(), - options: SyncOptions = SyncOptions.defaults + options: SyncOptions = SyncOptions.defaults, ) /** diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt index ecf6df93..6741777c 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt @@ -31,20 +31,32 @@ internal interface BucketStorage { @LegacySyncImplementation suspend fun getBucketStates(): List + @LegacySyncImplementation suspend fun getBucketOperationProgress(): Map + @LegacySyncImplementation suspend fun removeBuckets(bucketsToDelete: List) + @LegacySyncImplementation fun setTargetCheckpoint(checkpoint: Checkpoint) + @LegacySyncImplementation suspend fun saveSyncData(syncDataBatch: SyncDataBatch) + @LegacySyncImplementation suspend fun syncLocalDatabase( targetCheckpoint: Checkpoint, partialPriority: BucketPriority? = null, ): SyncLocalDatabaseResult - suspend fun control(op: String, payload: String?): List - suspend fun control(op: String, payload: ByteArray): List + suspend fun control( + op: String, + payload: String?, + ): List + + suspend fun control( + op: String, + payload: ByteArray, + ): List } diff --git a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt index 91e53a05..1bbc3a9e 100644 --- a/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/bucket/BucketStorageImpl.kt @@ -153,10 +153,10 @@ internal class BucketStorageImpl( val rows = db.getAll("SELECT name, count_at_last, count_since_last FROM ps_buckets") { cursor -> cursor.getString(0)!! to - LocalOperationCounters( - atLast = cursor.getLong(1)!!.toInt(), - sinceLast = cursor.getLong(2)!!.toInt(), - ) + LocalOperationCounters( + atLast = cursor.getLong(1)!!.toInt(), + sinceLast = cursor.getLong(2)!!.toInt(), + ) } for ((name, counters) in rows) { @@ -365,18 +365,22 @@ internal class BucketStorageImpl( return JsonUtil.json.decodeFromString>(result) } - override suspend fun control(op: String, payload: String?): List { - return db.writeTransaction { tx -> + override suspend fun control( + op: String, + payload: String?, + ): List = + db.writeTransaction { tx -> logger.v { "powersync_control($op, $payload)" } tx.get("SELECT powersync_control(?, ?) AS r", listOf(op, payload), ::handleControlResult) } - } - override suspend fun control(op: String, payload: ByteArray): List { - return db.writeTransaction { tx -> + override suspend fun control( + op: String, + payload: ByteArray, + ): List = + db.writeTransaction { tx -> logger.v { "powersync_control($op, binary payload)" } tx.get("SELECT powersync_control(?, ?) AS r", listOf(op, payload), ::handleControlResult) } - } } diff --git a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt index be919e49..9c8edbc3 100644 --- a/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt +++ b/core/src/commonMain/kotlin/com/powersync/connectors/PowerSyncBackendConnector.kt @@ -59,7 +59,7 @@ public abstract class PowerSyncBackendConnector { public open fun prefetchCredentials(): Job { fetchRequest?.takeIf { it.isActive }?.let { return it } - val request = + val request = scope.launch { fetchCredentials().also { value -> cachedCredentials = value diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 08e19bce..f2d9cf3a 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -2,7 +2,6 @@ package com.powersync.db import co.touchlab.kermit.Logger import com.powersync.DatabaseDriverFactory -import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase import com.powersync.PowerSyncException import com.powersync.bucket.BucketPriority @@ -153,7 +152,7 @@ internal class PowerSyncDatabaseImpl( crudThrottleMs: Long, retryDelayMs: Long, params: Map, - options: SyncOptions + options: SyncOptions, ) { waitReady() mutex.withLock { diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt index f7ee9b10..1fe66704 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/Instruction.kt @@ -17,28 +17,42 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.serializer +/** + * An instruction sent to this SDK by the core extension to implement sync behavior. + */ @Serializable(with = Instruction.Serializer::class) internal sealed interface Instruction { @Serializable - data class LogLine(val severity: String, val line: String): Instruction + data class LogLine( + val severity: String, + val line: String, + ) : Instruction @Serializable - data class UpdateSyncStatus(val status: CoreSyncStatus): Instruction + data class UpdateSyncStatus( + val status: CoreSyncStatus, + ) : Instruction @Serializable - data class EstablishSyncStream(val request: JsonObject): Instruction + data class EstablishSyncStream( + val request: JsonObject, + ) : Instruction @Serializable data class FetchCredentials( @SerialName("did_expire") - val didExpire: Boolean - ): Instruction + val didExpire: Boolean, + ) : Instruction - data object FlushSileSystem: Instruction - data object CloseSyncStream: Instruction - data object DidCompleteSync: Instruction + data object FlushSileSystem : Instruction - data class UnknownInstruction(val raw: JsonElement?): Instruction + data object CloseSyncStream : Instruction + + data object DidCompleteSync : Instruction + + data class UnknownInstruction( + val raw: JsonElement?, + ) : Instruction class Serializer : KSerializer { private val logLine = serializer() @@ -80,7 +94,10 @@ internal sealed interface Instruction { decodeSerializableElement(descriptor, 6, didCompleteSync) DidCompleteSync } - CompositeDecoder.UNKNOWN_NAME, -> UnknownInstruction(decodeSerializableElement(descriptor, index, serializer())) + CompositeDecoder.UNKNOWN_NAME -> + UnknownInstruction( + decodeSerializableElement(descriptor, index, serializer()), + ) CompositeDecoder.DECODE_DONE -> UnknownInstruction(null) else -> error("Unexpected index: $index") } @@ -93,7 +110,10 @@ internal sealed interface Instruction { } } - override fun serialize(encoder: Encoder, value: Instruction) { + override fun serialize( + encoder: Encoder, + value: Instruction, + ) { // We don't need this functionality, so... throw UnsupportedOperationException("Serializing instructions") } @@ -111,7 +131,7 @@ internal data class CoreSyncStatus( @Serializable internal data class CoreDownloadProgress( - val buckets: Map + val buckets: Map, ) @Serializable @@ -122,7 +142,7 @@ internal data class CoreBucketProgress( @SerialName("since_last") val sinceLast: Long, @SerialName("target_count") - val targetCount: Long + val targetCount: Long, ) @Serializable @@ -132,18 +152,19 @@ internal data class CorePriorityStatus( @Serializable(with = InstantTimestampSerializer::class) val lastSyncedAt: Instant?, @SerialName("has_synced") - val hasSynced: Boolean? + val hasSynced: Boolean?, ) private object InstantTimestampSerializer : KSerializer { override val descriptor: SerialDescriptor get() = PrimitiveSerialDescriptor("kotlinx.datetime.Instant", PrimitiveKind.LONG) - override fun deserialize(decoder: Decoder): Instant { - return Instant.fromEpochSeconds(decoder.decodeLong()) - } + override fun deserialize(decoder: Decoder): Instant = Instant.fromEpochSeconds(decoder.decodeLong()) - override fun serialize(encoder: Encoder, value: Instant) { + override fun serialize( + encoder: Encoder, + value: Instant, + ) { encoder.encodeLong(value.epochSeconds) } } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt b/core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt index fbd9f71c..f14077ff 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/LegacySyncImplementation.kt @@ -1,6 +1,10 @@ package com.powersync.sync -@RequiresOptIn(message = "Marker class for the old Kotlin-based sync implementation, making it easier to recognize classes we can remove after switching to the Rust sync implementation.") +@RequiresOptIn( + message = + "Marker class for the old Kotlin-based sync implementation, making it easier to " + + "recognize classes we can remove after switching to the Rust sync implementation.", +) @Retention(AnnotationRetention.BINARY) @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR) internal annotation class LegacySyncImplementation diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt index ae32f83d..831f5e3c 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt @@ -67,8 +67,7 @@ internal data class ProgressInfo( @ConsistentCopyVisibility public data class SyncDownloadProgress internal constructor( private val buckets: Map, -): ProgressWithOperations { - +) : ProgressWithOperations { override val downloadedOperations: Int override val totalOperations: Int @@ -79,7 +78,7 @@ public data class SyncDownloadProgress internal constructor( } @LegacySyncImplementation - internal constructor(localProgress: Map, target: Checkpoint): this( + internal constructor(localProgress: Map, target: Checkpoint) : this( buildMap { for (entry in target.checksums) { val savedProgress = localProgress[entry.bucket] @@ -94,7 +93,19 @@ public data class SyncDownloadProgress internal constructor( ), ) } - }) + }, + ) + + /** + * Returns download progress towards all data up until the specified [priority] being received. + * + * The returned [ProgressWithOperations] instance tracks the target amount of operations that need to be downloaded + * in total and how many of them have already been received. + */ + public fun untilPriority(priority: BucketPriority): ProgressWithOperations { + val (total, completed) = targetAndCompletedCounts(priority) + return ProgressInfo(totalOperations = total, downloadedOperations = completed) + } @LegacySyncImplementation internal fun incrementDownloaded(batch: SyncDataBatch): SyncDownloadProgress = @@ -114,23 +125,11 @@ public data class SyncDownloadProgress internal constructor( }, ) - /** - * Returns download progress towards all data up until the specified [priority] being received. - * - * The returned [ProgressWithOperations] instance tracks the target amount of operations that need to be downloaded - * in total and how many of them have already been received. - */ - public fun untilPriority(priority: BucketPriority): ProgressWithOperations { - val (total, completed) = targetAndCompletedCounts(priority) - return ProgressInfo(totalOperations = total, downloadedOperations = completed) - } - private fun targetAndCompletedCounts(priority: BucketPriority): Pair = buckets.values .asSequence() .filter { it.priority >= priority } .fold(0L to 0L) { (prevTarget, prevCompleted), entry -> (prevTarget + entry.targetCount - entry.atLast) to (prevCompleted + entry.sinceLast) - } - .let { it.first.toInt() to it.second.toInt() } + }.let { it.first.toInt() to it.second.toInt() } } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt b/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt index a5dfe548..12f3f43b 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/RSocketSupport.kt @@ -31,65 +31,84 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject import kotlin.coroutines.CoroutineContext +/** + * Connects to the RSocket endpoint for receiving sync lines. + * + * Note that we reconstruct the transport layer for RSocket by opening a WebSocket connection + * manually instead of using the high-level RSocket Ktor integration. + * The reason is that every request to the sync service needs its own metadata and data payload + * (e.g. to transmit the token), but the Ktor integration only supports setting a single payload for + * the entire client. + */ @OptIn(RSocketTransportApi::class, ExperimentalPowerSyncAPI::class) internal fun HttpClient.rSocketSyncStream( options: ConnectionMethod.WebSocket, req: JsonObject, credentials: PowerSyncCredentials, -): Flow = flow { - val flowContext = currentCoroutineContext() +): Flow = + flow { + val flowContext = currentCoroutineContext() - val websocketUri = URLBuilder(credentials.endpointUri("sync/stream")).apply { - protocol = when (protocolOrNull) { - URLProtocol.HTTP -> URLProtocol.WS - else -> URLProtocol.WSS - } - } + val websocketUri = + URLBuilder(credentials.endpointUri("sync/stream")).apply { + protocol = + when (protocolOrNull) { + URLProtocol.HTTP -> URLProtocol.WS + else -> URLProtocol.WSS + } + } + + // Note: We're using a custom connector here because we need to set options for each request + // without creating a new HTTP client each time. The recommended approach would be to add an + // RSocket extension to the HTTP client, but that only allows us to set the SETUP metadata for + // all connections (bad because we need a short-lived token in there). + // https://github.com/rsocket/rsocket-kotlin/issues/311 + val target = + object : RSocketClientTarget { + @RSocketTransportApi + override suspend fun connectClient(): RSocketConnection { + val ws = + webSocketSession { + url.takeFrom(websocketUri) + } + return KtorWebSocketConnection(ws) + } - // Note: We're using a custom connector here because we need to set options for each request - // without creating a new HTTP client each time. The recommended approach would be to add an - // RSocket extension to the HTTP client, but that only allows us to set the SETUP metadata for - // all connections (bad because we need a short-lived token in there). - // https://github.com/rsocket/rsocket-kotlin/issues/311 - val target = object : RSocketClientTarget { - @RSocketTransportApi - override suspend fun connectClient(): RSocketConnection { - val ws = webSocketSession { - url.takeFrom(websocketUri) + override val coroutineContext: CoroutineContext + get() = flowContext } - return KtorWebSocketConnection(ws) - } - override val coroutineContext: CoroutineContext - get() = flowContext - } + val connector = + RSocketConnector { + connectionConfig { + payloadMimeType = + PayloadMimeType( + metadata = "application/json", + data = "application/json", + ) - val connector = RSocketConnector { - connectionConfig { - payloadMimeType = PayloadMimeType( - metadata = "application/json", - data = "application/json" - ) + setupPayload { + buildPayload { + data("{}") + metadata(JsonUtil.json.encodeToString(ConnectionSetupMetadata(token = "Bearer ${credentials.token}"))) + } + } - setupPayload { - buildPayload { - data("{}") - metadata(JsonUtil.json.encodeToString(ConnectionSetupMetadata(token="Bearer ${credentials.token}"))) + keepAlive = options.keepAlive } } - keepAlive = options.keepAlive - } - } - - val rSocket = connector.connect(target) - val syncStream = rSocket.requestStream(buildPayload { - data(JsonUtil.json.encodeToString(req)) - metadata(JsonUtil.json.encodeToString(RequestStreamMetadata("/sync/stream"))) - }) + val rSocket = connector.connect(target) + val syncStream = + rSocket.requestStream( + buildPayload { + data(JsonUtil.json.encodeToString(req)) + metadata(JsonUtil.json.encodeToString(RequestStreamMetadata("/sync/stream"))) + }, + ) - emitAll(syncStream.map { it.data.readByteArray() }.flowOn(Dispatchers.IO)) -} + emitAll(syncStream.map { it.data.readByteArray() }.flowOn(Dispatchers.IO)) + } /** * The metadata payload we need to use when connecting with RSocket. @@ -100,7 +119,7 @@ internal fun HttpClient.rSocketSyncStream( private class ConnectionSetupMetadata( val token: String, @SerialName("user_agent") - val userAgent: String = userAgent() + val userAgent: String = userAgent(), ) /** @@ -108,5 +127,5 @@ private class ConnectionSetupMetadata( */ @Serializable private class RequestStreamMetadata( - val path: String + val path: String, ) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index 564e067e..d9b6dba2 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -1,7 +1,7 @@ package com.powersync.sync -import com.powersync.PowerSyncDatabase import com.powersync.ExperimentalPowerSyncAPI +import com.powersync.PowerSyncDatabase import io.rsocket.kotlin.keepalive.KeepAlive import kotlin.time.Duration.Companion.seconds @@ -13,15 +13,19 @@ import kotlin.time.Duration.Companion.seconds * the future. At the moment, the implementation is not covered by the stability guarantees we offer * for the rest of the SDK though. */ -public class SyncOptions @ExperimentalPowerSyncAPI constructor( - public val newClientImplementation: Boolean = false, - public val method: ConnectionMethod = ConnectionMethod.Http, -) { - public companion object { - @OptIn(ExperimentalPowerSyncAPI::class) - public val defaults: SyncOptions = SyncOptions() +public class SyncOptions + @ExperimentalPowerSyncAPI + constructor( + @property:ExperimentalPowerSyncAPI + public val newClientImplementation: Boolean = false, + @property:ExperimentalPowerSyncAPI + public val method: ConnectionMethod = ConnectionMethod.Http, + ) { + public companion object { + @OptIn(ExperimentalPowerSyncAPI::class) + public val defaults: SyncOptions = SyncOptions() + } } -} /** * The connection method to use when the SDK connects to the sync service. @@ -37,7 +41,7 @@ public sealed interface ConnectionMethod { * This is currently the default, but this will be changed once [WebSocket] support is stable. */ @ExperimentalPowerSyncAPI - public data object Http: ConnectionMethod + public data object Http : ConnectionMethod /** * Receive binary sync lines via RSocket over a WebSocket connection. @@ -47,13 +51,14 @@ public sealed interface ConnectionMethod { */ @ExperimentalPowerSyncAPI public data class WebSocket( - val keepAlive: KeepAlive = DefaultKeepAlive - ): ConnectionMethod { + val keepAlive: KeepAlive = DefaultKeepAlive, + ) : ConnectionMethod { private companion object { - val DefaultKeepAlive = KeepAlive( - interval = 20.0.seconds, - maxLifetime = 30.0.seconds, - ) + val DefaultKeepAlive = + KeepAlive( + interval = 20.0.seconds, + maxLifetime = 30.0.seconds, + ) } } } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt index 23af64db..06710b7a 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStatus.kt @@ -137,11 +137,14 @@ internal data class SyncStatusDataContainer( downloadProgress = status.downloading?.let { SyncDownloadProgress(it.buckets) }, lastSyncedAt = completeSync?.lastSyncedAt, hasSynced = completeSync != null, - priorityStatusEntries = status.priorityStatus.map { PriorityStatusEntry( - priority = it.priority, - lastSyncedAt = it.lastSyncedAt, - hasSynced = it.hasSynced, - ) } + priorityStatusEntries = + status.priorityStatus.map { + PriorityStatusEntry( + priority = it.priority, + lastSyncedAt = it.lastSyncedAt, + hasSynced = it.hasSynced, + ) + }, ) } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index bc6c5d95..0204892b 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -2,7 +2,6 @@ package com.powersync.sync import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity -import co.touchlab.stately.concurrency.AtomicBoolean import co.touchlab.stately.concurrency.AtomicReference import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException @@ -49,10 +48,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock -import kotlinx.io.Sink -import kotlinx.io.buffered -import kotlinx.io.files.Path -import kotlinx.io.files.SystemFileSystem import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement @@ -250,15 +245,21 @@ internal class SyncStream( } } - private fun connectViaWebSocket(req: JsonObject, options: ConnectionMethod.WebSocket): Flow = flow { - val credentials = requireNotNull(connector.getCredentialsCached()) { "Not logged in" } - - emitAll(httpClient.rSocketSyncStream( - options = options, - req = req, - credentials = credentials - )) - } + private fun connectViaWebSocket( + req: JsonObject, + options: ConnectionMethod.WebSocket, + ): Flow = + flow { + val credentials = requireNotNull(connector.getCredentialsCached()) { "Not logged in" } + + emitAll( + httpClient.rSocketSyncStream( + options = options, + req = req, + credentials = credentials, + ), + ) + } private suspend fun streamingSyncIteration() { coroutineScope { @@ -307,12 +308,18 @@ internal class SyncStream( fetchLinesJob?.join() } - private suspend fun control(op: String, payload: String? = null) { + private suspend fun control( + op: String, + payload: String? = null, + ) { val instructions = bucketStorage.control(op, payload) handleInstructions(instructions) } - private suspend fun control(op: String, payload: ByteArray) { + private suspend fun control( + op: String, + payload: ByteArray, + ) { val instructions = bucketStorage.control(op, payload) handleInstructions(instructions) } @@ -325,17 +332,18 @@ internal class SyncStream( when (instruction) { is Instruction.EstablishSyncStream -> { fetchLinesJob?.cancelAndJoin() - fetchLinesJob = scope.launch { - launch { - for (completion in completedCrudUploads) { - control("completed_upload") + fetchLinesJob = + scope.launch { + launch { + for (completion in completedCrudUploads) { + control("completed_upload") + } } - } - launch { - connect(instruction) + launch { + connect(instruction) + } } - } } Instruction.CloseSyncStream -> { fetchLinesJob!!.cancelAndJoin() @@ -346,11 +354,12 @@ internal class SyncStream( } is Instruction.LogLine -> { logger.log( - severity = when(instruction.severity) { - "DEBUG" -> Severity.Debug - "INFO" -> Severity.Debug - else -> Severity.Warn - }, + severity = + when (instruction.severity) { + "DEBUG" -> Severity.Debug + "INFO" -> Severity.Debug + else -> Severity.Warn + }, message = instruction.line, tag = logger.tag, throwable = null, @@ -367,12 +376,13 @@ internal class SyncStream( } else { // Token expires soon - refresh it in the background if (credentialsInvalidation == null) { - val job = scope.launch { - connector.prefetchCredentials().join() + val job = + scope.launch { + connector.prefetchCredentials().join() - // Token has been refreshed, start another iteration - stop() - } + // Token has been refreshed, start another iteration + stop() + } job.invokeOnCompletion { credentialsInvalidation = null } @@ -381,7 +391,7 @@ internal class SyncStream( } } Instruction.DidCompleteSync -> { - status.update { copy(downloadError=null) } + status.update { copy(downloadError = null) } } is Instruction.UnknownInstruction -> { throw PowerSyncException("Unknown instruction received from core extension: ${instruction.raw}", null) @@ -391,18 +401,22 @@ internal class SyncStream( private suspend fun connect(start: Instruction.EstablishSyncStream) { when (val method = options.method) { - ConnectionMethod.Http -> connectViaHttp(start.request).collect { rawLine -> - control("line_text", rawLine) - } - is ConnectionMethod.WebSocket -> connectViaWebSocket(start.request, method).collect { binaryLine -> - control("line_binary", binaryLine) - } + ConnectionMethod.Http -> + connectViaHttp(start.request).collect { rawLine -> + control("line_text", rawLine) + } + is ConnectionMethod.WebSocket -> + connectViaWebSocket(start.request, method).collect { binaryLine -> + control("line_binary", binaryLine) + } } } } @LegacySyncImplementation - private inner class LegacyIteration(val scope: CoroutineScope) { + private inner class LegacyIteration( + val scope: CoroutineScope, + ) { suspend fun streamingSyncIteration() { val bucketEntries = bucketStorage.getBucketStates() val initialBuckets = mutableMapOf() @@ -427,17 +441,18 @@ internal class SyncStream( ) lateinit var receiveLines: Job - receiveLines = scope.launch { - connectViaHttp(JsonUtil.json.encodeToJsonElement(req)).collect { value -> - val line = JsonUtil.json.decodeFromString(value) + receiveLines = + scope.launch { + connectViaHttp(JsonUtil.json.encodeToJsonElement(req)).collect { value -> + val line = JsonUtil.json.decodeFromString(value) - state = handleInstruction(line, value, state) + state = handleInstruction(line, value, state) - if (state.abortIteration) { - receiveLines.cancel() + if (state.abortIteration) { + receiveLines.cancel() + } } } - } receiveLines.join() status.update { abortedDownload() } diff --git a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt index 5d1535c6..b169f88f 100644 --- a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt @@ -1,5 +1,4 @@ import co.touchlab.kermit.Logger -import com.powersync.bucket.BucketState import com.powersync.bucket.BucketStorageImpl import com.powersync.db.crud.CrudEntry import com.powersync.db.crud.UpdateType diff --git a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt index f0447692..41cee90a 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -159,7 +159,7 @@ class SyncStreamTest { logger = logger, params = JsonObject(emptyMap()), uploadScope = this, - options = SyncOptions() + options = SyncOptions(), ) // Launch streaming sync in a coroutine that we'll cancel after verification diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index bb0a1c5f..aef9b6e4 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -32,6 +32,7 @@ public actual class DatabaseDriverFactory { migrateDriver(driver, schema) + // TODO Revert driver.loadExtensions( "/Users/simon/src/powersync-sqlite-core/target/debug/libpowersync.dylib" to "sqlite3_powersync_init", ) From 2c2332f840d07d7ccc66658caa4a1f0bdf7b8a87 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 12 Jun 2025 11:32:33 +0200 Subject: [PATCH 12/16] Fix sync progress regression --- .../kotlin/com/powersync/sync/SyncIntegrationTest.kt | 1 + core/src/commonMain/kotlin/com/powersync/sync/Progress.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index de823ca8..ec75164f 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -28,6 +28,7 @@ import io.kotest.matchers.shouldBe import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt index 7c801be3..d260624c 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/Progress.kt @@ -143,7 +143,7 @@ public data class SyncDownloadProgress internal constructor( put( bucket.bucket, previous.copy( - sinceLast = min(previous.sinceLast + bucket.data.size, previous.total), + sinceLast = min(previous.sinceLast + bucket.data.size, previous.targetCount), ), ) } From 3caf26dc6f5df5409118d2b231a34244c4bef2c0 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 12 Jun 2025 11:58:46 +0200 Subject: [PATCH 13/16] Fix passing parameters --- .../com/powersync/sync/SyncIntegrationTest.kt | 24 +++++++++++++++++++ .../com/powersync/testutils/TestUtils.kt | 14 ++++++++++- .../kotlin/com/powersync/sync/SyncStream.kt | 8 ++++++- .../powersync/testutils/MockSyncService.kt | 2 ++ gradle.properties | 1 + 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index ec75164f..b6b214eb 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -17,18 +17,22 @@ import com.powersync.db.schema.Schema import com.powersync.testutils.UserRow import com.powersync.testutils.databaseTest import com.powersync.testutils.waitFor +import com.powersync.utils.JsonParam import com.powersync.utils.JsonUtil import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.verify import dev.mokkery.verifyNoMoreCalls import dev.mokkery.verifySuspend +import io.kotest.matchers.collections.shouldHaveSingleElement import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -60,6 +64,26 @@ abstract class BaseSyncIntegrationTest( } } + @Test + fun useParameters() = + databaseTest { + database.connect(connector, options = options, params = mapOf("foo" to JsonParam.String("bar"))) + turbineScope(timeout = 10.0.seconds) { + val turbine = database.currentStatus.asFlow().testIn(this) + turbine.waitFor { it.connected } + turbine.cancel() + } + + requestedSyncStreams shouldHaveSingleElement { + val params = it.jsonObject["parameters"]!!.jsonObject + params.keys shouldHaveSingleElement "foo" + params.values + .first() + .jsonPrimitive.content shouldBe "bar" + true + } + } + @Test @OptIn(DelicateCoroutinesApi::class) fun closesResponseStreamOnDatabaseClose() = diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index f55f2440..b2d980da 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -18,15 +18,18 @@ import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.SyncLine +import com.powersync.utils.JsonUtil import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.mock import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.mock.toByteArray import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import kotlinx.io.files.Path +import kotlinx.serialization.json.JsonElement expect val factory: DatabaseDriverFactory @@ -87,6 +90,7 @@ internal class ActiveDatabaseTest( @OptIn(LegacySyncImplementation::class) var syncLines = Channel() + var requestedSyncStreams = mutableListOf() var checkpointResponse: () -> WriteCheckpointResponse = { WriteCheckpointResponse(WriteCheckpointData("1000")) } @@ -129,7 +133,15 @@ internal class ActiveDatabaseTest( suspend fun openDatabaseAndInitialize(): PowerSyncDatabaseImpl = openDatabase().also { it.readLock { } } private fun createClient(config: HttpClientConfig<*>.() -> Unit): HttpClient { - val engine = MockSyncService(syncLines) { checkpointResponse() } + val engine = + MockSyncService( + lines = syncLines, + generateCheckpoint = { checkpointResponse() }, + trackSyncRequest = { + val parsed = JsonUtil.json.parseToJsonElement(it.body.toByteArray().decodeToString()) + requestedSyncStreams.add(parsed) + }, + ) return HttpClient(engine) { config() diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index c1bfdbae..0b0c637a 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -300,7 +301,12 @@ internal class SyncStream( var credentialsInvalidation: Job? = null, ) { suspend fun start() { - control("start", JsonUtil.json.encodeToString(params)) + @Serializable + class StartParameters( + val parameters: JsonObject, + ) + + control("start", JsonUtil.json.encodeToString(StartParameters(params))) fetchLinesJob?.join() } diff --git a/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt b/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt index 591750dc..bc564e97 100644 --- a/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt +++ b/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt @@ -37,6 +37,7 @@ import kotlinx.serialization.encodeToString internal class MockSyncService( private val lines: ReceiveChannel, private val generateCheckpoint: () -> WriteCheckpointResponse, + private val trackSyncRequest: suspend (HttpRequestData) -> Unit, ) : HttpClientEngineBase("sync-service") { override val config: HttpClientEngineConfig get() = Config @@ -52,6 +53,7 @@ internal class MockSyncService( val scope = CoroutineScope(context) return if (data.url.encodedPath == "/sync/stream") { + trackSyncRequest(data) val job = scope.writer { lines.consume { diff --git a/gradle.properties b/gradle.properties index 6c4128c3..faa0a235 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,6 +2,7 @@ kotlin.code.style=official # Gradle org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" org.gradle.caching=true +org.gradle.configuration-cache=true # Compose org.jetbrains.compose.experimental.uikit.enabled=true # Android From ee121e77cef963d8637e5f58a6e74526598932fc Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 12 Jun 2025 13:22:20 +0200 Subject: [PATCH 14/16] Disable configuration cache --- gradle.properties | 1 - 1 file changed, 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index faa0a235..6c4128c3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,6 @@ kotlin.code.style=official # Gradle org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" org.gradle.caching=true -org.gradle.configuration-cache=true # Compose org.jetbrains.compose.experimental.uikit.enabled=true # Android From eeade050f6712aa6725a8a263e709f0d33696966 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 12 Jun 2025 13:41:05 +0200 Subject: [PATCH 15/16] Revert extension loading tmp changes --- .../jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index aef9b6e4..39864b54 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -32,9 +32,8 @@ public actual class DatabaseDriverFactory { migrateDriver(driver, schema) - // TODO Revert driver.loadExtensions( - "/Users/simon/src/powersync-sqlite-core/target/debug/libpowersync.dylib" to "sqlite3_powersync_init", + powersyncExtension to "sqlite3_powersync_init", ) val mappedDriver = PsSqlDriver(driver = driver) From 6618b23edd42f52fc92ac8bed4290220edbee6a9 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 12 Jun 2025 13:41:49 +0200 Subject: [PATCH 16/16] Revert changes to demo for now --- .../src/jvmMain/kotlin/com/powersync/demos/Main.kt | 6 ------ .../src/commonMain/kotlin/com/powersync/demos/Auth.kt | 6 +----- 2 files changed, 1 insertion(+), 11 deletions(-) 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 index f4dca20e..483df5c0 100644 --- 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 @@ -7,15 +7,9 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import co.touchlab.kermit.platformLogWriter fun main() { - Logger.setLogWriters(platformLogWriter()) - Logger.setMinSeverity(Severity.Verbose) - application { Window( onCloseRequest = ::exitApplication, diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt index 8a266636..846cd531 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt @@ -5,8 +5,6 @@ import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.powersync.PowerSyncDatabase import com.powersync.connector.supabase.SupabaseConnector -import com.powersync.sync.ConnectionMethod -import com.powersync.sync.SyncOptions import io.github.jan.supabase.auth.status.RefreshFailureCause import io.github.jan.supabase.auth.status.SessionStatus import kotlinx.coroutines.flow.MutableStateFlow @@ -46,9 +44,7 @@ internal class AuthViewModel( supabase.sessionStatus.collect { when (it) { is SessionStatus.Authenticated -> { - // TODO REMOVE - val options = SyncOptions(method = ConnectionMethod.WebSocket()) - db.connect(supabase, options = options) + db.connect(supabase) } is SessionStatus.NotAuthenticated -> { db.disconnectAndClear()