From fa96c038783e440905f8f82c99d8659eb0df04e2 Mon Sep 17 00:00:00 2001 From: Clemente Date: Fri, 28 Jun 2024 23:42:28 +0200 Subject: [PATCH] Add sync migrations with basic test cases --- .../internal/interop/CoreErrorConverter.kt | 3 + .../interop/InvalidSchemaException.kt | 18 + .../kotlin/internal/interop/RealmInterop.kt | 2 + .../kotlin/internal/interop/RealmInterop.kt | 7 + .../kotlin/internal/interop/ErrorCode.kt | 2 +- .../kotlin/internal/interop/RealmInterop.kt | 5 + packages/external/core | 2 +- .../kotlin/io/realm/kotlin/Configuration.kt | 12 - .../io/realm/kotlin/RealmConfiguration.kt | 12 + .../mongodb/internal/SyncConfigurationImpl.kt | 45 ++- .../kotlin/mongodb/sync/SyncConfiguration.kt | 16 + .../kotlin/test/mongodb/util/AppAdmin.kt | 2 +- .../test/mongodb/util/AppServicesClient.kt | 60 +++- .../common/SyncSchemaMigrationTests.kt | 319 ++++++++++++++++++ 14 files changed, 483 insertions(+), 22 deletions(-) create mode 100644 packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/InvalidSchemaException.kt create mode 100644 packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/SyncSchemaMigrationTests.kt 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..0cfb3655e7 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,9 @@ object CoreErrorConverter { return userError ?: when { ErrorCode.RLM_ERR_INDEX_OUT_OF_BOUNDS == errorCode -> IndexOutOfBoundsException(message) + ErrorCode.RLM_ERR_INVALID_SCHEMA_CHANGE == errorCode || + ErrorCode.RLM_ERR_INVALID_SCHEMA_VERSION == errorCode -> + InvalidSchemaException(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/InvalidSchemaException.kt b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/InvalidSchemaException.kt new file mode 100644 index 0000000000..4b6d570bf1 --- /dev/null +++ b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/InvalidSchemaException.kt @@ -0,0 +1,18 @@ +/* + * 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 + +class InvalidSchemaException(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..e98022573e 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) 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..edbd127897 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())) } 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..4cdfabf37b 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. diff --git a/packages/external/core b/packages/external/core index c280bdb175..d4ec79bb6d 160000 --- a/packages/external/core +++ b/packages/external/core @@ -1 +1 @@ -Subproject commit c280bdb17522323d5c30dc32a2b9efc9dc80ca3b +Subproject commit d4ec79bb6dc6e92831d417ebaeebb2a47c2e7657 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/SyncConfigurationImpl.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SyncConfigurationImpl.kt index 012736d418..cbb1b49c0a 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 @@ -23,13 +23,17 @@ import io.realm.kotlin.internal.RealmImpl import io.realm.kotlin.internal.TypedFrozenRealmImpl import io.realm.kotlin.internal.interop.AsyncOpenCallback import io.realm.kotlin.internal.interop.FrozenRealmPointer +import io.realm.kotlin.internal.interop.InvalidSchemaException import io.realm.kotlin.internal.interop.LiveRealmPointer +import io.realm.kotlin.internal.interop.LiveRealmT +import io.realm.kotlin.internal.interop.NativePointer import io.realm.kotlin.internal.interop.RealmAppPointer import io.realm.kotlin.internal.interop.RealmAsyncOpenTaskPointer import io.realm.kotlin.internal.interop.RealmConfigurationPointer import io.realm.kotlin.internal.interop.RealmInterop import io.realm.kotlin.internal.interop.RealmSyncConfigurationPointer import io.realm.kotlin.internal.interop.RealmSyncSessionPointer +import io.realm.kotlin.internal.interop.SchemaMode import io.realm.kotlin.internal.interop.SyncAfterClientResetHandler import io.realm.kotlin.internal.interop.SyncBeforeClientResetHandler import io.realm.kotlin.internal.interop.SyncErrorCallback @@ -84,14 +88,15 @@ internal class SyncConfigurationImpl( // unnecessary pressure on the server. val fileExists: Boolean = fileExists(configuration.path) val asyncOpenCreatedRealmFile: AtomicBoolean = atomic(false) - if (initialRemoteData != null && !fileExists) { + + if ((!fileExists && initialRemoteData != null) || (fileExists && isSyncMigrationPending())) { // 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(initialRemoteData!!.timeout.inWholeMilliseconds) { + withContext(realm.writeScheduler.dispatcher) { val callback = AsyncOpenCallback { error: Throwable? -> if (error != null) { channel.trySend(error) @@ -99,7 +104,6 @@ internal class SyncConfigurationImpl( channel.trySend(true) } } - val configPtr = createNativeConfiguration() taskPointer.value = RealmInterop.realm_open_synchronized(configPtr) RealmInterop.realm_async_open_task_start(taskPointer.value!!, callback) @@ -138,6 +142,37 @@ 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 Realm one. + * + * Opening a Realm with a config set to SchemaMode::Immutable would validate that the schema versions + * match throwing an error if they differ. + * + * Immutable schema mode is only compatible with local Realms. + */ + private fun isSyncMigrationPending(): Boolean = + try { + // We need to open synced Realm as local to be able to use `RLM_SCHEMA_MODE_IMMUTABLE` + // RLM_SCHEMA_MODE_IMMUTABLE would throw if the persisted realm and configured schema versions + // differ. + val config = configuration.createNativeConfiguration() + RealmInterop.realm_config_set_schema_mode( + config = config, + mode = SchemaMode.RLM_SCHEMA_MODE_IMMUTABLE + ) + + val realmPtr: NativePointer = RealmInterop.realm_open(config) + RealmInterop.realm_close(realmPtr) + logger.debug("Sync migration not required") + false + } catch (e: InvalidSchemaException) { + logger.debug("Sync migration required: ${e.message}") + true + } catch (e: Exception) { + throw e + } + override suspend fun initializeRealmData(realm: RealmImpl, realmFileCreated: Boolean) { // Create or update subscriptions for Flexible Sync realms as needed. initialSubscriptions?.let { initialSubscriptionsConfig -> @@ -173,7 +208,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 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..dc7c1565d4 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 @@ -18,6 +18,7 @@ package io.realm.kotlin.mongodb.sync import io.realm.kotlin.Configuration import io.realm.kotlin.MutableRealm import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration import io.realm.kotlin.TypedRealm import io.realm.kotlin.internal.ConfigurationImpl import io.realm.kotlin.internal.ContextLogger @@ -453,6 +454,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.waitForServerChanges = InitialRemoteDataConfiguration(timeout) + } + } + @Suppress("LongMethod") override fun build(): SyncConfiguration { val realmLogger = ContextLogger() diff --git a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppAdmin.kt b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppAdmin.kt index cbef34d0d3..aa2b6e00d0 100644 --- a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppAdmin.kt +++ b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppAdmin.kt @@ -210,7 +210,7 @@ class AppAdminImpl( override suspend fun waitForSyncBootstrap() { baasClient.run { - var limit = 300 + val limit = 300 var i = 0 while (!app.initialSyncComplete() && i < limit) { delay(1.seconds) 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 a754eb792b..e3db4ce9ba 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 @@ -71,6 +71,8 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.serializer import kotlin.reflect.KClass +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.minutes private const val ADMIN_PATH = "/api/admin/v3.0" private const val PRIVATE_PATH = "/api/private/v1.0" @@ -403,7 +405,7 @@ class AppServicesClient( suspend fun BaasApp.setSchema( schema: Set>, extraProperties: Map = emptyMap() - ) { + ): Map { val schemas = SchemaProcessor.process( databaseName = clientAppId, classes = schema, @@ -423,6 +425,29 @@ 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.addFunction(function: Function): Function = @@ -436,13 +461,44 @@ class AppServicesClient( } } + 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/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..a852e6eb87 --- /dev/null +++ b/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/SyncSchemaMigrationTests.kt @@ -0,0 +1,319 @@ +@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.sync.InitialSubscriptionsCallback +import io.realm.kotlin.mongodb.sync.SyncConfiguration +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.BaseRealmObject +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.assertFailsWith +import kotlin.test.assertNotNull + +/** + * Tests for [io.realm.kotlin.mongodb.sync.Sync] that is accessed through + * [io.realm.kotlin.mongodb.App.sync]. + */ + +@PersistedName("Dog") +class DogV0 : RealmObject { + @PrimaryKey + var _id: BsonObjectId = BsonObjectId() + + var name: String = "" + + var breed: String = "" +} + +@PersistedName("Dog") +class DogV1 : RealmObject { + @PrimaryKey + var _id: BsonObjectId = BsonObjectId() + + var name: String = "" +} + +@PersistedName("Dog") +class DogV2 : RealmObject { + @PrimaryKey + var _id: BsonObjectId = BsonObjectId() + + var name: String? = "" +} + +@PersistedName("Dog") +class DogV3 : RealmObject { + @PrimaryKey + var _id: BsonObjectId = BsonObjectId() + + var name: String? = "" + + var breed: ObjectId = ObjectId() +} + +class SyncSchemaMigrationTests { + + private lateinit var user: User + private lateinit var app: App + + fun openRealm( + schema: Set>, + version: Long, + initialSubscriptionBlock: InitialSubscriptionsCallback = InitialSubscriptionsCallback { }, + ) = Realm.open( + SyncConfiguration + .Builder( + schema = schema, + user = user, + ) + .waitForInitialRemoteData() + .initialSubscriptions(false, initialSubscriptionBlock) + .schemaVersion(version) + .build() + ) + + @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) + ) + + app.mongodbService.setSyncConfig( + """ + { + "flexible_sync": { + "state": "enabled", + "database_name": "$database", + "is_recovery_mode_disabled": false, + "queryable_fields_names": [ + "name", + "section", + "stringField", + "location", + "selector" + ], + "asymmetric_tables": [ + "AsymmetricA", + "Measurement" + ] + } + } + """.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) + ) + + // TODO do we support deleting tables? +// app.deleteSchema(namesToIds["Dog"]!!) + + app.waitForSchemaVersion(2) + }) {} + ) + + val (email, password) = TestHelper.randomEmail() to "password1234" + user = runBlocking { + app.createUserAndLogIn(email, password) + } + } + + @AfterTest + fun tearDown() { + if (this::app.isInitialized) { + app.asTestApp.close() + } + } + + // bump version but same schema + @Test + fun bumpVersionNotSchema() { + openRealm( + schema = setOf(DogV0::class), + version = 0, + ) { realm -> + add(realm.query()) + }.use { + assertNotNull(it) + } + + // Destructive change on server schema + assertFailsWith { + openRealm( + schema = setOf(DogV0::class), + version = 1, + ).use { + assertNotNull(it) + } + } + } + + // change schema but not version + @Test + fun changeSchemaButNotVersion() { + openRealm( + schema = setOf(DogV0::class), + version = 0, + ) { realm -> + add(realm.query()) + }.use { + assertNotNull(it) + } + + // It is a destructive change on the client schema so it works normally. + openRealm( + schema = setOf(DogV1::class), + version = 0, + ).use { + assertNotNull(it) + } + + // Invalid schema change + assertFailsWith("The following changes cannot be made in additive-only schema mode") { + openRealm( + schema = setOf(DogV2::class), + version = 0, + ).use { + assertNotNull(it) + } + } + } + + // fails with future schema version + @Test + fun failsWithFutureSchemaVersion() { + assertFailsWithMessage("Client provided invalid schema version") { + openRealm( + schema = setOf(DogV2::class), + version = 5, + ) { realm -> + add(realm.query()) + }.use { + assertNotNull(it) + } + } + } + + // migrate consecutive + @Test + fun migrateConsecutiveVersions() { + openRealm( + schema = setOf(DogV0::class), + version = 0, + ) { realm -> + add(realm.query()) + }.use { + assertNotNull(it) + } + + openRealm( + schema = setOf(DogV1::class), + version = 1, + ).use { + assertNotNull(it) + } + + // DogV2 and DogV3 share same schema version + // because they only differ with additive changes. + openRealm( + schema = setOf(DogV2::class), + version = 2, + ).use { + assertNotNull(it) + } + + openRealm( + schema = setOf(DogV3::class), + version = 2, + ).use { + assertNotNull(it) + } + } + + + // migrate skipping + @Test + fun migrateSkippingVersions() { + openRealm( + schema = setOf(DogV0::class), + version = 0, + ) { realm -> + add(realm.query()) + }.use { + assertNotNull(it) + } + + openRealm( + schema = setOf(DogV2::class), + version = 2, + ).use { + assertNotNull(it) + } + } + + // migrate skipping + @Test + fun downgradeSchema() { + openRealm( + schema = setOf(DogV2::class), + version = 2, + ) { realm -> + add(realm.query()) + }.use { + assertNotNull(it) + } + + openRealm( + schema = setOf(DogV0::class), + version = 0, + ).use { + assertNotNull(it) + } + } +}