From fcae6cc2ce56c17ac064054f738d0f0f5c9c3a40 Mon Sep 17 00:00:00 2001 From: Bastian Kruck Date: Thu, 5 Feb 2026 17:31:51 +0100 Subject: [PATCH] feat(model-client): allow starting replicated model in js with versionHash and repositoryId --- .gitignore | 1 + .../modelix/model/client2/ReplicatedModel.kt | 84 ++++++++++++++++--- .../org/modelix/model/client2/ClientJS.kt | 20 ++++- .../model/client2/ReplicatedModelHashTest.kt | 42 ++++++++++ vue-model-api/src/useReplicatedModel.test.ts | 2 +- vue-model-api/src/useReplicatedModels.test.ts | 2 +- 6 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 model-client/src/jsTest/kotlin/org/modelix/model/client2/ReplicatedModelHashTest.kt diff --git a/.gitignore b/.gitignore index 6efaab0b80..90f975607f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +bin .gradle/ **/build /*/ignite/ diff --git a/model-client/src/commonMain/kotlin/org/modelix/model/client2/ReplicatedModel.kt b/model-client/src/commonMain/kotlin/org/modelix/model/client2/ReplicatedModel.kt index 777d4f306f..f88502f3ac 100644 --- a/model-client/src/commonMain/kotlin/org/modelix/model/client2/ReplicatedModel.kt +++ b/model-client/src/commonMain/kotlin/org/modelix/model/client2/ReplicatedModel.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -20,6 +21,7 @@ import org.modelix.model.api.INodeReference import org.modelix.model.api.runSynchronized import org.modelix.model.lazy.BranchReference import org.modelix.model.lazy.CLVersion +import org.modelix.model.lazy.RepositoryId import org.modelix.model.mutable.IGenericMutableModelTree import org.modelix.model.mutable.IMutableModelTree import org.modelix.model.mutable.INodeIdGenerator @@ -56,27 +58,52 @@ import org.modelix.model.mutable.asModel * Dispose should be called on this, as otherwise a regular polling will go on. * * @property client the model client to connect to the model server - * @property branchRef the model server branch to fetch the data from + * @property branchRef branch or repository reference * @property providedScope the CoroutineScope to use for the suspendable tasks * @property initialRemoteVersion the last version on the server from which we want to start the synchronization */ class ReplicatedModel( val client: IModelClientV2, - val branchRef: BranchReference, + private val branchRefOrNull: BranchReference?, val idGenerator: (TreeId) -> INodeIdGenerator, private val providedScope: CoroutineScope? = null, initialRemoteVersion: CLVersion? = null, + repositoryId: RepositoryId? = null, + versionHash: String? = null, ) : Closeable { + + constructor( + client: IModelClientV2, + branchRef: BranchReference, + idGenerator: (TreeId) -> INodeIdGenerator, + providedScope: CoroutineScope? = null, + initialRemoteVersion: CLVersion? = null, + ) : this(client, branchRef, idGenerator, providedScope, initialRemoteVersion, null, null) + + val branchRef: BranchReference get() = branchRefOrNull ?: throw IllegalStateException("ReplicatedModel is in read-only version mode") + private val scope = providedScope ?: CoroutineScope(Dispatchers.Default) private var state = State.New private var localModel: LocalModel? = null - private val remoteVersion = RemoteVersion(client, branchRef, initialRemoteVersion) + + private val remoteVersion: IRemoteVersion + private var pollingJob: Job? = null init { if (initialRemoteVersion != null) { localModel = LocalModel(initialRemoteVersion, client.getIdGenerator(), idGenerator(initialRemoteVersion.getModelTree().getId())) { client.getUserId() } } + + if (branchRefOrNull != null) { + check(versionHash == null) { "Cannot provide both branchRef and versionHash" } + remoteVersion = RemoteVersionFromBranch(client, branchRefOrNull, initialRemoteVersion) + } else if (versionHash != null) { + val repoId = repositoryId ?: throw IllegalArgumentException("repositoryId is required when versionHash is provided") + remoteVersion = RemoteVersionFromHash(client, repoId, versionHash) + } else { + throw IllegalArgumentException("Either branchRef or versionHash must be provided") + } } private fun getLocalModel(): LocalModel = checkNotNull(localModel) { "Model is not initialized yet" } @@ -92,7 +119,7 @@ class ReplicatedModel( state = State.Starting if (localModel == null) { - val initialVersion = remoteVersion.pull() + val initialVersion = remoteVersion.getInitialVersion() localModel = LocalModel(initialVersion, client.getIdGenerator(), idGenerator(initialVersion.getModelTree().getId())) { client.getUserId() } } @@ -106,10 +133,10 @@ class ReplicatedModel( remoteVersionReceived(newRemoteVersion, null) nextDelayMs = 0 } catch (ex: CancellationException) { - LOG.debug { "Stop polling branch $branchRef after disposing." } + LOG.debug { "Stop polling after disposing." } throw ex } catch (ex: Throwable) { - LOG.error(ex) { "Failed polling branch $branchRef" } + LOG.error(ex) { "Failed polling" } nextDelayMs = (nextDelayMs * 3 / 2).coerceIn(1000, 30000) } } @@ -134,7 +161,9 @@ class ReplicatedModel( } suspend fun resetToServerVersion() { - getLocalModel().resetToVersion(client.pull(branchRef, lastKnownVersion = null).upcast()) + // This delegates to remoteVersion which handles pull/load + val version = remoteVersion.getInitialVersion() + getLocalModel().resetToVersion(version) } fun isDisposed(): Boolean = state == State.Disposed @@ -308,16 +337,22 @@ private class LocalModel(initialVersion: CLVersion, val versionIdGenerator: IIdG } } -private class RemoteVersion( +private interface IRemoteVersion { + suspend fun getInitialVersion(): CLVersion + suspend fun poll(): CLVersion + suspend fun push(version: CLVersion): CLVersion +} + +private class RemoteVersionFromBranch( val client: IModelClientV2, val branchRef: BranchReference, private var lastKnownRemoteVersion: CLVersion? = null, -) { +) : IRemoteVersion { private val unconfirmedVersions: MutableSet = LinkedHashSet() fun getNumberOfUnconfirmed() = runSynchronized(unconfirmedVersions) { unconfirmedVersions.size } - suspend fun pull(): CLVersion { + override suspend fun getInitialVersion(): CLVersion { return versionReceived( client.pull( branchRef, @@ -332,11 +367,11 @@ private class RemoteVersion( ) } - suspend fun poll(): CLVersion { + override suspend fun poll(): CLVersion { return versionReceived(client.poll(branchRef, lastKnownVersion = lastKnownRemoteVersion).upcast()) } - suspend fun push(version: CLVersion): CLVersion { + override suspend fun push(version: CLVersion): CLVersion { if (lastKnownRemoteVersion?.getContentHash() == version.getContentHash()) return version runSynchronized(unconfirmedVersions) { if (!unconfirmedVersions.add(version.getContentHash())) return version @@ -359,4 +394,29 @@ private class RemoteVersion( } } +private class RemoteVersionFromHash( + val client: IModelClientV2, + val repositoryId: RepositoryId, + val versionHash: String, + private var lastKnownRemoteVersion: CLVersion? = null, +) : IRemoteVersion { + + override suspend fun getInitialVersion(): CLVersion { + return client.loadVersion( + repositoryId, + versionHash, + lastKnownRemoteVersion, + ).upcast().also { lastKnownRemoteVersion = it } + } + + override suspend fun poll(): CLVersion { + // let's pretent to do something. The version is actually immutable and won't ever change… + awaitCancellation() + } + + override suspend fun push(version: CLVersion): CLVersion { + throw UnsupportedOperationException("Read-only model") + } +} + private fun IVersion.upcast(): CLVersion = this as CLVersion diff --git a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt index ed263c40d4..e18343b5e4 100644 --- a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt +++ b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt @@ -209,9 +209,14 @@ interface ClientJS { @JsExport data class ReplicatedModelParameters( val repositoryId: String, - val branchId: String, + val branchId: String? = null, val idScheme: IdSchemeJS, -) + val versionHash: String? = null, +) { + init { + require((branchId != null) xor (versionHash != null)) { "Exactly one of branchId or versionHash must be provided" } + } +} internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS { @@ -289,13 +294,20 @@ internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS { return GlobalScope.promise { val models = parameters.map { parameters -> val modelClient = modelClient - val branchReference = RepositoryId(parameters.repositoryId).getBranchReference(parameters.branchId) + val repositoryId = RepositoryId(parameters.repositoryId) + val branchReference = parameters.branchId?.let { repositoryId.getBranchReference(it) } val idGenerator: (TreeId) -> INodeIdGenerator = when (parameters.idScheme) { IdSchemeJS.READONLY -> { treeId -> DummyIdGenerator() } IdSchemeJS.MODELIX -> { treeId -> ModelixIdGenerator(modelClient.getIdGenerator(), treeId) } IdSchemeJS.MPS -> { treeId -> MPSIdGenerator(modelClient.getIdGenerator(), treeId) } } - modelClient.getReplicatedModel(branchReference, idGenerator).also { it.start() } + ReplicatedModel( + client = modelClient, + branchRefOrNull = branchReference, + idGenerator = idGenerator, + versionHash = parameters.versionHash, + repositoryId = repositoryId, + ).also { it.start() } } ReplicatedModelJSImpl(models) } diff --git a/model-client/src/jsTest/kotlin/org/modelix/model/client2/ReplicatedModelHashTest.kt b/model-client/src/jsTest/kotlin/org/modelix/model/client2/ReplicatedModelHashTest.kt new file mode 100644 index 0000000000..5cf419fce0 --- /dev/null +++ b/model-client/src/jsTest/kotlin/org/modelix/model/client2/ReplicatedModelHashTest.kt @@ -0,0 +1,42 @@ +package org.modelix.model.client2 + +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class ReplicatedModelHashTest { + + @Test + fun ReplicatedModelParameters_validation() { + // Valid: branchId + // branchId is positional 2nd arg. + ReplicatedModelParameters("repo", "branch", IdSchemeJS.MODELIX) + + // Valid: versionHash + // branchId must be null. + ReplicatedModelParameters( + repositoryId = "repo", + branchId = null, + idScheme = IdSchemeJS.MODELIX, + versionHash = "hash", + ) + + // Invalid: both branchId and versionHash + assertFailsWith { + ReplicatedModelParameters( + repositoryId = "repo", + branchId = "branch", + idScheme = IdSchemeJS.MODELIX, + versionHash = "hash", + ) + } + + // Invalid: neither + assertFailsWith { + ReplicatedModelParameters( + repositoryId = "repo", + branchId = null, + idScheme = IdSchemeJS.MODELIX, + ) + } + } +} diff --git a/vue-model-api/src/useReplicatedModel.test.ts b/vue-model-api/src/useReplicatedModel.test.ts index 1ced6e7db8..1e056be132 100644 --- a/vue-model-api/src/useReplicatedModel.test.ts +++ b/vue-model-api/src/useReplicatedModel.test.ts @@ -47,7 +47,7 @@ test("test wrapper backwards compatibility", (done) => { // Mock implementation that returns a dummy object with a branch const branchId = parameters[0].branchId; const rootNode = loadModelsFromJson([JSON.stringify({ root: {} })]); - rootNode.setPropertyValue(toRoleJS("branchId"), branchId); + rootNode.setPropertyValue(toRoleJS("branchId"), branchId ?? undefined); const branch = { rootNode, diff --git a/vue-model-api/src/useReplicatedModels.test.ts b/vue-model-api/src/useReplicatedModels.test.ts index 92f8842380..d17c06d31e 100644 --- a/vue-model-api/src/useReplicatedModels.test.ts +++ b/vue-model-api/src/useReplicatedModels.test.ts @@ -53,7 +53,7 @@ test("test branch connects", (done) => { const branchId = parameters[0].branchId; return Promise.resolve( new SuccessfulReplicatedModelJS( - branchId, + branchId!, ) as unknown as ReplicatedModelJS, ); }