diff --git a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/CoreErrorConverter.kt b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/CoreErrorConverter.kt index 0a60a8aa7b..5db314279d 100644 --- a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/CoreErrorConverter.kt +++ b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/CoreErrorConverter.kt @@ -38,6 +38,8 @@ object CoreErrorConverter { return userError ?: when { ErrorCode.RLM_ERR_INDEX_OUT_OF_BOUNDS == errorCode -> IndexOutOfBoundsException(message) + ErrorCode.RLM_ERR_INVALID_SCHEMA_VERSION == errorCode -> + InvalidSchemaVersionException(message) ErrorCategory.RLM_ERR_CAT_INVALID_ARG in categories && ErrorCategory.RLM_ERR_CAT_SYNC_ERROR !in categories -> { // Some sync errors flagged as both logical and illegal. In our case, we consider those // IllegalState, so discard them them here and let them fall through to the bottom case diff --git a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/InvalidSchemaVersionException.kt b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/InvalidSchemaVersionException.kt new file mode 100644 index 0000000000..ef4f683a77 --- /dev/null +++ b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/InvalidSchemaVersionException.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Realm Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.realm.kotlin.internal.interop + +/** + * Exception thrown when there is a mismatch between the schema version defined in the configuration + * and the persisted one. + */ +class InvalidSchemaVersionException(override val message: String?) : IllegalStateException() diff --git a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index e012f627d4..7e92372bbd 100644 --- a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -227,6 +227,8 @@ expect object RealmInterop { // dispatcher. The realm itself must also be opened on the same thread fun realm_open(config: RealmConfigurationPointer, scheduler: RealmSchedulerPointer): Pair + fun realm_open(config: RealmConfigurationPointer): LiveRealmPointer + // Opening a Realm asynchronously. Only supported for synchronized realms. fun realm_open_synchronized(config: RealmConfigurationPointer): RealmAsyncOpenTaskPointer fun realm_async_open_task_start(task: RealmAsyncOpenTaskPointer, callback: AsyncOpenCallback) @@ -647,6 +649,10 @@ expect object RealmInterop { fun realm_sync_client_config_set_pong_keepalive_timeout(syncClientConfig: RealmSyncClientConfigurationPointer, timeoutMs: ULong) fun realm_sync_client_config_set_fast_reconnect_limit(syncClientConfig: RealmSyncClientConfigurationPointer, timeoutMs: ULong) + fun realm_get_persisted_schema_version( + config: RealmConfigurationPointer + ): Long + fun realm_sync_config_new( user: RealmUserPointer, partition: String diff --git a/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index facb5d5814..ca0f796567 100644 --- a/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -241,6 +241,13 @@ actual object RealmInterop { return Pair(realmPtr, fileCreated) } + actual fun realm_open( + config: RealmConfigurationPointer, + ): LiveRealmPointer { + val realmPtr = LongPointerWrapper(realmc.realm_open(config.cptr())) + return realmPtr + } + actual fun realm_open_synchronized(config: RealmConfigurationPointer): RealmAsyncOpenTaskPointer { return LongPointerWrapper(realmc.realm_open_synchronized(config.cptr())) } @@ -1438,6 +1445,10 @@ actual object RealmInterop { realmc.realm_sync_client_config_set_fast_reconnect_limit(syncClientConfig.cptr(), timeoutMs.toLong()) } + actual fun realm_get_persisted_schema_version( + config: RealmConfigurationPointer + ): Long = realmc.realm_get_persisted_schema_version(config.cptr()) + actual fun realm_network_transport_new(networkTransport: NetworkTransport): RealmNetworkTransportPointer { return LongPointerWrapper(realmc.realm_network_transport_new(networkTransport)) } diff --git a/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/ErrorCode.kt b/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/ErrorCode.kt index 9c790f1cfd..383bf32464 100644 --- a/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/ErrorCode.kt +++ b/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/ErrorCode.kt @@ -192,7 +192,7 @@ actual enum class ErrorCode( actual companion object { actual fun of(nativeValue: Int): ErrorCode? = - values().firstOrNull { value -> + entries.firstOrNull { value -> value.nativeValue == nativeValue } } diff --git a/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index da0ae06505..2f8dda12cd 100644 --- a/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -556,6 +556,11 @@ actual object RealmInterop { return Pair(realmPtr, fileCreated.value) } + actual fun realm_open(config: RealmConfigurationPointer): LiveRealmPointer { + val realmPtr = CPointerWrapper(realm_wrapper.realm_open(config.cptr())) + return realmPtr + } + actual fun realm_create_scheduler(): RealmSchedulerPointer { // If there is no notification dispatcher use the default scheduler. // Re-verify if this is actually needed when notification scheduler is fully in place. @@ -2607,6 +2612,10 @@ actual object RealmInterop { realm_wrapper.realm_sync_client_config_set_fast_reconnect_limit(syncClientConfig.cptr(), timeoutMs) } + actual fun realm_get_persisted_schema_version( + config: RealmConfigurationPointer + ): Long = realm_wrapper.realm_get_persisted_schema_version(config.cptr()).toLong() + actual fun realm_sync_config_set_error_handler( syncConfig: RealmSyncConfigurationPointer, errorHandler: SyncErrorCallback diff --git a/packages/external/core b/packages/external/core index c280bdb175..fc97025802 160000 --- a/packages/external/core +++ b/packages/external/core @@ -1 +1 @@ -Subproject commit c280bdb17522323d5c30dc32a2b9efc9dc80ca3b +Subproject commit fc97025802effcea92d80f6038e5c8ba1a05560e diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Configuration.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Configuration.kt index cd261b23bf..4bfbaaffbc 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Configuration.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Configuration.kt @@ -283,18 +283,6 @@ public interface Configuration { this.writeDispatcher = dispatcher } as S - /** - * Sets the schema version of the Realm. This must be equal to or higher than the schema - * version of the existing Realm file, if any. If the schema version is higher than the - * already existing Realm, a migration is needed. - */ - public fun schemaVersion(schemaVersion: Long): S { - if (schemaVersion < 0) { - throw IllegalArgumentException("Realm schema version numbers must be 0 (zero) or higher. Yours was: $schemaVersion") - } - return apply { this.schemaVersion = schemaVersion } as S - } - /** * Sets the 64 byte key used to encrypt and decrypt the Realm file. If no key is provided * the Realm file will be unencrypted. diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/RealmConfiguration.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/RealmConfiguration.kt index 7ef592841c..af4ac36a9c 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/RealmConfiguration.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/RealmConfiguration.kt @@ -132,6 +132,18 @@ public interface RealmConfiguration : Configuration { this.automaticEmbeddedObjectConstraintsResolution = resolveEmbeddedObjectConstraints } + /** + * Sets the schema version of the Realm. This must be equal to or higher than the schema + * version of the existing Realm file, if any. If the schema version is higher than the + * already existing Realm, a migration is needed. + */ + public fun schemaVersion(schemaVersion: Long): Builder { + if (schemaVersion < 0) { + throw IllegalArgumentException("Realm schema version numbers must be 0 (zero) or higher. Yours was: $schemaVersion") + } + return apply { this.schemaVersion = schemaVersion } + } + override fun name(name: String): Builder = apply { checkName(name) this.name = name diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SubscriptionSetImpl.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SubscriptionSetImpl.kt index 6f0baef67b..ae04564bc5 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SubscriptionSetImpl.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SubscriptionSetImpl.kt @@ -98,6 +98,7 @@ internal class SubscriptionSetImpl( channel.trySend(false) } else -> { + println("STATE $state") // Ignore all other states, wait for either complete or error. } } diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SyncConfigurationImpl.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SyncConfigurationImpl.kt index 012736d418..c67cbb4feb 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SyncConfigurationImpl.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SyncConfigurationImpl.kt @@ -47,6 +47,7 @@ import io.realm.kotlin.mongodb.sync.RecoverOrDiscardUnsyncedChangesStrategy import io.realm.kotlin.mongodb.sync.RecoverUnsyncedChangesStrategy import io.realm.kotlin.mongodb.sync.SyncClientResetStrategy import io.realm.kotlin.mongodb.sync.SyncConfiguration +import io.realm.kotlin.mongodb.sync.SyncMigrationRemoteDataConfiguration import io.realm.kotlin.mongodb.sync.SyncMode import io.realm.kotlin.mongodb.sync.SyncSession import kotlinx.atomicfu.AtomicBoolean @@ -69,7 +70,8 @@ internal class SyncConfigurationImpl( override val errorHandler: SyncSession.ErrorHandler, override val syncClientResetStrategy: SyncClientResetStrategy, override val initialSubscriptions: InitialSubscriptionsConfiguration?, - override val initialRemoteData: InitialRemoteDataConfiguration? + override val initialRemoteData: InitialRemoteDataConfiguration?, + override val schemaMigrationRemoteData: SyncMigrationRemoteDataConfiguration?, ) : InternalConfiguration by configuration, SyncConfiguration { override suspend fun openRealm(realm: RealmImpl): Pair { @@ -84,14 +86,28 @@ internal class SyncConfigurationImpl( // unnecessary pressure on the server. val fileExists: Boolean = fileExists(configuration.path) val asyncOpenCreatedRealmFile: AtomicBoolean = atomic(false) - if (initialRemoteData != null && !fileExists) { + + val configPtr = createNativeConfiguration() + + if ( + (!fileExists && initialRemoteData != null) || + (fileExists && isSyncMigrationPending(configPtr)) + ) { + // There are two different timeout: + // - initial remote data timeout, when it is the first time we open the Realm. + // - schema migration timeout, when a sync schema migration is required. + val timeout = if (fileExists) + schemaMigrationRemoteData!!.timeout + else + initialRemoteData!!.timeout + // Channel to work around not being able to use `suspendCoroutine` to wrap the callback, as // that results in the `Continuation` being frozen, which breaks it. val channel = Channel(1) val taskPointer: AtomicRef = atomic(null) try { - val result: Any = withTimeout(initialRemoteData.timeout.inWholeMilliseconds) { - withContext(realm.notificationScheduler.dispatcher) { + val result: Any = withTimeout(timeout.inWholeMilliseconds) { + withContext(realm.writeScheduler.dispatcher) { val callback = AsyncOpenCallback { error: Throwable? -> if (error != null) { channel.trySend(error) @@ -100,7 +116,6 @@ internal class SyncConfigurationImpl( } } - val configPtr = createNativeConfiguration() taskPointer.value = RealmInterop.realm_open_synchronized(configPtr) RealmInterop.realm_async_open_task_start(taskPointer.value!!, callback) channel.receive() @@ -111,6 +126,7 @@ internal class SyncConfigurationImpl( // Track whether or not async open created the file. asyncOpenCreatedRealmFile.value = true } + is Throwable -> throw result else -> throw IllegalStateException("Unexpected value: $result") } @@ -138,6 +154,17 @@ internal class SyncConfigurationImpl( return Pair(result.first, result.second || asyncOpenCreatedRealmFile.value) } + /** + * Checks whether a sync Realm requires a migration, this happens when the schema version provided in + * the config differs from the persisted one. + */ + internal fun isSyncMigrationPending(configPtr: RealmConfigurationPointer): Boolean = + if (fileExists(configuration.path)) { + RealmInterop.realm_get_persisted_schema_version(configPtr) != configuration.schemaVersion + } else { + false + } + override suspend fun initializeRealmData(realm: RealmImpl, realmFileCreated: Boolean) { // Create or update subscriptions for Flexible Sync realms as needed. initialSubscriptions?.let { initialSubscriptionsConfig -> @@ -173,7 +200,7 @@ internal class SyncConfigurationImpl( return syncInitializer(ptr) } - private val syncInitializer: (RealmConfigurationPointer) -> RealmConfigurationPointer + private var syncInitializer: (RealmConfigurationPointer) -> RealmConfigurationPointer init { // We need to freeze `errorHandler` reference on initial thread @@ -184,12 +211,16 @@ internal class SyncConfigurationImpl( val initializerHelper = when (resetStrategy) { is DiscardUnsyncedChangesStrategy -> DiscardUnsyncedChangesHelper(resetStrategy, configuration) + is ManuallyRecoverUnsyncedChangesStrategy -> ManuallyRecoverUnsyncedChangesHelper(resetStrategy) + is RecoverUnsyncedChangesStrategy -> RecoverUnsyncedChangesHelper(resetStrategy, configuration) + is RecoverOrDiscardUnsyncedChangesStrategy -> RecoverOrDiscardUnsyncedChangesHelper(resetStrategy, configuration) + else -> throw IllegalArgumentException("Unsupported client reset strategy: $resetStrategy") } @@ -263,7 +294,7 @@ private interface ClientResetStrategyHelper { private abstract class OnBeforeOnAfterHelper constructor( val strategy: T, - val configuration: InternalConfiguration + val configuration: InternalConfiguration, ) : ClientResetStrategyHelper { abstract fun getResyncMode(): SyncSessionResyncMode @@ -285,7 +316,7 @@ private abstract class OnBeforeOnAfterHelper constr private class RecoverOrDiscardUnsyncedChangesHelper constructor( strategy: RecoverOrDiscardUnsyncedChangesStrategy, - configuration: InternalConfiguration + configuration: InternalConfiguration, ) : OnBeforeOnAfterHelper(strategy, configuration) { override fun getResyncMode(): SyncSessionResyncMode = @@ -303,7 +334,7 @@ private class RecoverOrDiscardUnsyncedChangesHelper constructor( override fun onAfterReset( realmBefore: FrozenRealmPointer, realmAfter: LiveRealmPointer, - didRecover: Boolean + didRecover: Boolean, ) { // Needed to allow writes on the Mutable after Realm RealmInterop.realm_begin_write(realmAfter) @@ -338,7 +369,7 @@ private class RecoverOrDiscardUnsyncedChangesHelper constructor( override fun onSyncError( session: SyncSession, appPointer: RealmAppPointer, - error: SyncError + error: SyncError, ) { // If there is a user exception we appoint it as the cause of the client reset strategy.onManualResetFallback( @@ -350,7 +381,7 @@ private class RecoverOrDiscardUnsyncedChangesHelper constructor( private class RecoverUnsyncedChangesHelper constructor( strategy: RecoverUnsyncedChangesStrategy, - configuration: InternalConfiguration + configuration: InternalConfiguration, ) : OnBeforeOnAfterHelper(strategy, configuration) { override fun getResyncMode(): SyncSessionResyncMode = @@ -368,7 +399,7 @@ private class RecoverUnsyncedChangesHelper constructor( override fun onAfterReset( realmBefore: FrozenRealmPointer, realmAfter: LiveRealmPointer, - didRecover: Boolean + didRecover: Boolean, ) { // Needed to allow writes on the Mutable after Realm RealmInterop.realm_begin_write(realmAfter) @@ -400,7 +431,7 @@ private class RecoverUnsyncedChangesHelper constructor( override fun onSyncError( session: SyncSession, appPointer: RealmAppPointer, - error: SyncError + error: SyncError, ) { // If there is a user exception we appoint it as the cause of the client reset strategy.onManualResetFallback( @@ -412,7 +443,7 @@ private class RecoverUnsyncedChangesHelper constructor( private class DiscardUnsyncedChangesHelper constructor( strategy: DiscardUnsyncedChangesStrategy, - configuration: InternalConfiguration + configuration: InternalConfiguration, ) : OnBeforeOnAfterHelper(strategy, configuration) { override fun getResyncMode(): SyncSessionResyncMode = @@ -430,7 +461,7 @@ private class DiscardUnsyncedChangesHelper constructor( override fun onAfterReset( realmBefore: FrozenRealmPointer, realmAfter: LiveRealmPointer, - didRecover: Boolean + didRecover: Boolean, ) { // Needed to allow writes on the Mutable after Realm RealmInterop.realm_begin_write(realmAfter) @@ -462,7 +493,7 @@ private class DiscardUnsyncedChangesHelper constructor( override fun onSyncError( session: SyncSession, appPointer: RealmAppPointer, - error: SyncError + error: SyncError, ) { strategy.onManualResetFallback( session, @@ -472,7 +503,7 @@ private class DiscardUnsyncedChangesHelper constructor( } private class ManuallyRecoverUnsyncedChangesHelper( - val strategy: ManuallyRecoverUnsyncedChangesStrategy + val strategy: ManuallyRecoverUnsyncedChangesStrategy, ) : ClientResetStrategyHelper { override fun initialize(nativeSyncConfig: RealmSyncConfigurationPointer) { @@ -485,7 +516,7 @@ private class ManuallyRecoverUnsyncedChangesHelper( override fun onSyncError( session: SyncSession, appPointer: RealmAppPointer, - error: SyncError + error: SyncError, ) { strategy.onClientReset( session, diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt index b2c53632a1..9ad45697a0 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt @@ -114,6 +114,22 @@ public data class InitialRemoteDataConfiguration( val timeout: Duration = Duration.INFINITE ) +/** + * Configuration options if [SyncConfiguration.Builder.waitForInitialRemoteData] is + * enabled. + */ +public data class SyncMigrationRemoteDataConfiguration( + + /** + * The timeout used when downloading any initial data server the first time the + * Realm is opened. + * + * If the timeout is hit, opening a Realm will throw an + * [io.realm.mongodb.exceptions.DownloadingRealmTimeOutException]. + */ + val timeout: Duration = Duration.INFINITE +) + /** * Configuration options if [SyncConfiguration.Builder.initialSubscriptions] is * enabled. @@ -204,6 +220,11 @@ public interface SyncConfiguration : Configuration { */ public val initialRemoteData: InitialRemoteDataConfiguration? + /** + * TODO + */ + public val schemaMigrationRemoteData: SyncMigrationRemoteDataConfiguration? + /** * Used to create a [SyncConfiguration]. For common use cases, a [SyncConfiguration] can be * created using the [SyncConfiguration.create] function. @@ -222,6 +243,7 @@ public interface SyncConfiguration : Configuration { private var syncClientResetStrategy: SyncClientResetStrategy? = null private var initialSubscriptions: InitialSubscriptionsConfiguration? = null private var waitForServerChanges: InitialRemoteDataConfiguration? = null + private var waitForSchemaMigration: SyncMigrationRemoteDataConfiguration? = null /** * Creates a [SyncConfiguration.Builder] for Flexible Sync. Flexible Sync must be enabled @@ -453,6 +475,21 @@ public interface SyncConfiguration : Configuration { ) } + /** + * Sets the schema version of the Realm. This must be equal to or higher than the schema + * version of the existing Realm file, if any. If the schema version is higher than the + * already existing Realm, a migration is needed. + */ + public fun schemaVersion(schemaVersion: Long, timeout: Duration = Duration.INFINITE): Builder { + if (schemaVersion < 0) { + throw IllegalArgumentException("Realm schema version numbers must be 0 (zero) or higher. Yours was: $schemaVersion") + } + return apply { + this.schemaVersion = schemaVersion + this.waitForSchemaMigration = SyncMigrationRemoteDataConfiguration(timeout) + } + } + @Suppress("LongMethod") override fun build(): SyncConfiguration { val realmLogger = ContextLogger() @@ -536,7 +573,8 @@ public interface SyncConfiguration : Configuration { errorHandler!!, // It will never be null: either default or user-provided syncClientResetStrategy!!, // It will never be null: either default or user-provided initialSubscriptions, - waitForServerChanges + waitForServerChanges, + waitForSchemaMigration, ) } diff --git a/packages/test-sync/build.gradle.kts b/packages/test-sync/build.gradle.kts index d8ad09ebe1..2067ca0332 100644 --- a/packages/test-sync/build.gradle.kts +++ b/packages/test-sync/build.gradle.kts @@ -101,6 +101,7 @@ kotlin { implementation("io.ktor:ktor-client-logging:${Versions.ktor}") implementation("io.ktor:ktor-serialization-kotlinx-json:${Versions.ktor}") implementation("io.ktor:ktor-client-content-negotiation:${Versions.ktor}") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.serialization}") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.serialization}") diff --git a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppServicesClient.kt b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppServicesClient.kt index 8f151ab5a9..8e8212d284 100644 --- a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppServicesClient.kt +++ b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppServicesClient.kt @@ -394,7 +394,7 @@ class AppServicesClient( suspend fun BaasApp.setSchema( schema: Set>, extraProperties: Map = emptyMap() - ) { + ): Map { val schemas = SchemaProcessor.process( databaseName = clientAppId, classes = schema, @@ -414,15 +414,69 @@ class AppServicesClient( schema = schema ) } + + return ids } + suspend fun BaasApp.updateSchema( + ids: Map, + schema: Set>, + extraProperties: Map = emptyMap() + ) { + val schemas = SchemaProcessor.process( + databaseName = clientAppId, + classes = schema, + extraProperties = extraProperties + ) + + // then we update the schema to add the relationships + schemas.forEach { (name, schema) -> + updateSchema( + id = ids[name]!!, + schema = schema, + bypassServiceChange = true + ) + } + } + + suspend fun BaasApp.waitForSchemaVersion(expectedVersion: Int) { + return withTimeout(1.minutes) { + withContext(dispatcher) { + while (true) { + val response = httpClient.typedRequest( + Get, + "$url/sync/schemas/versions" + ) + + response["versions"]!!.jsonArray.forEach { version -> + if (version.jsonObject["version_major"]!!.jsonPrimitive.int >= expectedVersion) { + return@withContext + } + } + } + } + } + } + + suspend fun BaasApp.deleteSchema( + id: String, + ): HttpResponse = + withContext(dispatcher) { + httpClient.request( + "$url/schemas/$id?bypass_service_change=SyncSchemaVersionIncrease" + ) { + this.method = HttpMethod.Delete + } + } + suspend fun BaasApp.updateSchema( id: String, schema: Schema, + bypassServiceChange: Boolean = false ): HttpResponse = withContext(dispatcher) { httpClient.request( - "$url/schemas/$id" + "$url/schemas/$id${if (bypassServiceChange) "?bypass_service_change=SyncSchemaVersionIncrease" else ""}" ) { this.method = HttpMethod.Put setBody(json.encodeToJsonElement(schema)) diff --git a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/TestAppInitializer.kt b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/TestAppInitializer.kt index 5647d6f32c..0a5d25d3c9 100644 --- a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/TestAppInitializer.kt +++ b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/TestAppInitializer.kt @@ -109,7 +109,6 @@ open class BaseAppInitializer( with(client) { block?.invoke(this, app) } - app.setDevelopmentMode(true) } } } diff --git a/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/SyncSchemaMigrationTests.kt b/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/SyncSchemaMigrationTests.kt new file mode 100644 index 0000000000..abfe46fb1e --- /dev/null +++ b/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/SyncSchemaMigrationTests.kt @@ -0,0 +1,616 @@ +/* + * Copyright 2024 Realm Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("invisible_member", "invisible_reference") + +package io.realm.kotlin.test.mongodb.common + +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import io.realm.kotlin.internal.platform.runBlocking +import io.realm.kotlin.mongodb.App +import io.realm.kotlin.mongodb.User +import io.realm.kotlin.mongodb.exceptions.BadFlexibleSyncQueryException +import io.realm.kotlin.mongodb.exceptions.DownloadingRealmTimeOutException +import io.realm.kotlin.mongodb.internal.SyncConfigurationImpl +import io.realm.kotlin.mongodb.sync.SyncConfiguration +import io.realm.kotlin.mongodb.syncSession +import io.realm.kotlin.test.mongodb.TestApp +import io.realm.kotlin.test.mongodb.asTestApp +import io.realm.kotlin.test.mongodb.common.utils.assertFailsWithMessage +import io.realm.kotlin.test.mongodb.createUserAndLogIn +import io.realm.kotlin.test.mongodb.syncServerAppName +import io.realm.kotlin.test.mongodb.util.BaseAppInitializer +import io.realm.kotlin.test.mongodb.util.addEmailProvider +import io.realm.kotlin.test.util.TestHelper +import io.realm.kotlin.test.util.use +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.PersistedName +import io.realm.kotlin.types.annotations.PrimaryKey +import kotlinx.coroutines.delay +import org.mongodb.kbson.BsonObjectId +import org.mongodb.kbson.ObjectId +import kotlin.reflect.KClass +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@PersistedName("Dog") +class DogV0 : RealmObject { + @PrimaryKey + var _id: BsonObjectId = BsonObjectId() + + var owner: String = "" + + var name: String = "" + + var breed: String = "" +} + +@PersistedName("Dog") +class DogV1 : RealmObject { + @PrimaryKey + var _id: BsonObjectId = BsonObjectId() + + var owner: String = "" + + var name: String = "" +} + +@PersistedName("Dog") +class DogV2 : RealmObject { + @PrimaryKey + var _id: BsonObjectId = BsonObjectId() + + var owner: String = "" + + var name: String? = "" +} + +@PersistedName("Dog") +class DogV3 : RealmObject { + @PrimaryKey + var _id: BsonObjectId = BsonObjectId() + + var owner: String = "" + + var name: String? = "" + + var breed: ObjectId = ObjectId() +} + +@PersistedName("Cat") +class CatV0 : RealmObject { + @PrimaryKey + var _id: BsonObjectId = BsonObjectId() + + var owner: String = "" + + var name: String? = "" +} + +class SyncSchemaMigrationTests { + + private lateinit var user: User + private lateinit var app: App + + fun createSyncConfig( + dogSchema: KClass, + catSchema: KClass? = null, + version: Long, + ) = SyncConfiguration + .Builder( + schema = setOfNotNull(dogSchema, catSchema), + user = user, + ) + .waitForInitialRemoteData(timeout = 30.seconds) + .initialSubscriptions(false) { realm -> + add(realm.query(dogSchema, "owner = $0", user.id)) + catSchema?.let { + add(realm.query(catSchema, "owner = $0", user.id)) + } + } + .schemaVersion(version, timeout = 30.seconds) + .build() + + private val SyncConfiguration.isSyncMigrationPending: Boolean + // get() = true + get() { + (this as SyncConfigurationImpl) + + val configPtr = this.createNativeConfiguration() + return this.isSyncMigrationPending(configPtr) + } + + @BeforeTest + fun setup() { + app = TestApp( + this::class.simpleName, + object : BaseAppInitializer(syncServerAppName("schver"), { app -> + addEmailProvider(app) + + val database = app.clientAppId + + val namesToIds = app.setSchema( + schema = setOf(DogV0::class, CatV0::class) + ) + + app.mongodbService.setSyncConfig( + """ + { + "flexible_sync": { + "state": "enabled", + "database_name": "$database", + "is_recovery_mode_disabled": false, + "queryable_fields_names": [ + "owner" + ] + } + } + """.trimIndent() + ) + + while (!app.initialSyncComplete()) { + delay(500) + } + + app.updateSchema( + ids = namesToIds, + schema = setOf(DogV1::class) + ) + + app.updateSchema( + ids = namesToIds, + schema = setOf(DogV2::class) + ) + + // Additive change does not bump version + app.updateSchema( + ids = namesToIds, + schema = setOf(DogV3::class) + ) + + // Deleting an schema would bump the version + app.deleteSchema(namesToIds["Cat"]!!) + + app.waitForSchemaVersion(3) + }) {} + ) + + val (email, password) = TestHelper.randomEmail() to "password1234" + user = runBlocking { + app.createUserAndLogIn(email, password) + } + } + + @AfterTest + fun tearDown() { + if (this::app.isInitialized) { + app.asTestApp.close() + } + } + + @Test + fun validateDefaultTimeout() { + val config = SyncConfiguration + .Builder( + schema = setOf(), + user = user, + ) + .schemaVersion(0) + .build() + + assertNull(config.initialRemoteData) + assertEquals(Duration.INFINITE, config.schemaMigrationRemoteData!!.timeout) + } + + @Test + fun validateTimeouts() { + SyncConfiguration + .Builder( + schema = setOf(), + user = user, + ) + .waitForInitialRemoteData(timeout = 60.seconds) + .schemaVersion(0, timeout = 30.seconds) + .build() + .let { config -> + assertEquals(60.seconds, config.initialRemoteData!!.timeout) + assertEquals(30.seconds, config.schemaMigrationRemoteData!!.timeout) + } + } + + // bump version but same schema + @Test + fun bumpVersionNotSchema() { + createSyncConfig( + dogSchema = DogV0::class, + version = 0, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + + // Destructive change on server schema + assertFailsWith { + createSyncConfig( + dogSchema = DogV0::class, + version = 1, + ).let { config -> + assertTrue(config.isSyncMigrationPending) + + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + } + } + + // change schema but not version + @Test + fun changeSchemaButNotVersion() { + createSyncConfig( + dogSchema = DogV0::class, + version = 0, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + + // It is a destructive change on the client schema so it works normally. + createSyncConfig( + dogSchema = DogV1::class, + version = 0, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + + // Invalid schema change + assertFailsWith("The following changes cannot be made in additive-only schema mode") { + createSyncConfig( + dogSchema = DogV2::class, + version = 0, + ).let { config -> + // Destructive schema change would trigger a migration, even if schema versions + // match. + assertFalse(config.isSyncMigrationPending) + + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + } + } + + // fails with future schema version + @Test + fun failsWithNonExistingSchemaVersionFirstOpen() { + assertFailsWithMessage("Client provided invalid schema version") { + createSyncConfig( + dogSchema = DogV2::class, + version = 5, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + } + } + + @Test + fun failsWithNonExistingSchemaVersion() { + createSyncConfig( + dogSchema = DogV0::class, + version = 0, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + + assertFailsWithMessage("Client provided invalid schema version") { + createSyncConfig( + dogSchema = DogV2::class, + version = 5, + ).let { config -> + assertTrue(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + } + } + + // migrate consecutive + @Test + fun migrateConsecutiveVersions() { + createSyncConfig( + dogSchema = DogV0::class, + version = 0, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + + createSyncConfig( + dogSchema = DogV1::class, + version = 1, + ).let { config -> + assertTrue(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + + // DogV2 and DogV3 share same schema version + // because they only differ with additive changes. + createSyncConfig( + dogSchema = DogV2::class, + version = 2, + ).let { config -> + assertTrue(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + + createSyncConfig( + dogSchema = DogV3::class, + version = 2, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + } + + // migrate skipping + @Test + fun migrateSkippingVersions() { + createSyncConfig( + dogSchema = DogV0::class, + version = 0, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + + createSyncConfig( + dogSchema = DogV2::class, + version = 2, + ).let { config -> + assertTrue(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + } + + // migrate skipping + @Test + fun downgradeSchema() { + createSyncConfig( + dogSchema = DogV2::class, + version = 2, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + + createSyncConfig( + dogSchema = DogV0::class, + version = 0, + ).let { config -> + assertTrue(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + } + } + } + + @Test + fun dataVisibility_consecutive() { + // DogV0 is incompatible with DogV3 changes + + // There is an issue in the baas server that prevents + // from bootstrapping when a property type changes. + // + // see https://jira.mongodb.org/browse/BAAS-31935 + +// createSyncConfig( +// schema = DogV0::class, +// version = 0, +// ).let { config -> +// assertFalse(config.isSyncMigrationPending) +// Realm.open(config).use { realm -> +// assertNotNull(realm) +// assertEquals(0, realm.query().count().find()) +// +// realm.writeBlocking { +// copyToRealm( +// DogV0().apply { +// name = "v0" +// } +// ) +// } +// +// runBlocking { +// realm.syncSession.uploadAllLocalChanges() +// } +// } +// } + + createSyncConfig( + dogSchema = DogV1::class, + catSchema = CatV0::class, + version = 1, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + assertEquals(0, realm.query("name = 'v0'").count().find()) + assertEquals(0, realm.query("name = 'v0'").count().find()) + + realm.writeBlocking { + copyToRealm( + DogV1().apply { + name = "v1" + owner = user.id + } + ) + + copyToRealm( + CatV0().apply { + name = "v0" + owner = user.id + } + ) + } + + runBlocking { + realm.syncSession.uploadAllLocalChanges() + } + } + } + + createSyncConfig( + dogSchema = DogV2::class, + catSchema = CatV0::class, + version = 2, + ).let { config -> + assertTrue(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + assertEquals(0, realm.query("name = 'v0'").count().find()) + assertEquals(1, realm.query("name = 'v1'").count().find()) + assertEquals(0, realm.query("name = 'v0'").count().find()) + + realm.writeBlocking { + copyToRealm( + DogV2().apply { + name = "v2" + owner = user.id + } + ) + + copyToRealm( + CatV0().apply { + name = "v0" + owner = user.id + } + ) + } + + runBlocking { + realm.syncSession.uploadAllLocalChanges() + } + } + } + + createSyncConfig( + dogSchema = DogV3::class, + version = 3, + ).let { config -> + assertTrue(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + assertEquals(0, realm.query("name = 'v0'").count().find()) + assertEquals(1, realm.query("name = 'v1'").count().find()) + assertEquals(1, realm.query("name = 'v2'").count().find()) + + realm.writeBlocking { + copyToRealm( + DogV3().apply { + name = "v3" + owner = user.id + } + ) + } + + assertEquals(1, realm.query("name = 'v3'").count().find()) + + runBlocking { + realm.syncSession.uploadAllLocalChanges() + } + } + } + } + + // There is an issue in the baas server that prevents + // from bootstrapping when a property type changes. + // + // see https://jira.mongodb.org/browse/BAAS-31935 + @Test + fun dataVisibility_downgradeWithPropertyTypeChange_throws() { + createSyncConfig( + dogSchema = DogV3::class, + version = 3, + ).let { config -> + assertFalse(config.isSyncMigrationPending) + Realm.open(config).use { realm -> + assertNotNull(realm) + assertEquals(0, realm.query().count().find()) + + realm.writeBlocking { + copyToRealm( + DogV3().apply { + name = "v3" + owner = user.id + } + ) + } + + runBlocking { + realm.syncSession.uploadAllLocalChanges() + } + } + } + + createSyncConfig( + dogSchema = DogV0::class, + version = 0, + ).let { config -> + assertTrue(config.isSyncMigrationPending) + assertFailsWith { + Realm.open(config) + } + } + } +}