From 96f5c834f03506438db95ca9ae4dcdf25ea7a2c9 Mon Sep 17 00:00:00 2001 From: Bastian Kruck Date: Wed, 18 Feb 2026 09:22:39 +0100 Subject: [PATCH 1/2] fix(model-api): allow getReferenceTargetNode(getReferenceRoles().first) The vue-model-api had a **leaky abstraction** around role handling: 1. **INodeJS API contract** promised: "Role objects returned from `getReferenceRoles()` can be passed back unchanged" 2. **Implementation reality**: `NodeAdapterJS.getReferenceRoles()` returns `IRoleReference` objects (Kotlin), but `ReactiveINodeJS` was forced to cast them to strings to work with `MapWithRoleKey.getOrPut(roleString: String)` 3. **Consequence**: As a result, we got a TypeError when calling getReferenceTargetNode(getReferenceRoles().first) --- .../kotlin/org/modelix/model/api/IRole.kt | 16 ++ .../kotlin/ReferenceSerializationTests.kt | 15 ++ .../org/modelix/model/api/MapWithRoleKey.kt | 20 +- .../org/modelix/model/api/NodeAdapterJS.kt | 43 +---- .../modelix/model/api/MapWithRoleKeyTest.kt | 181 ++++++++++++++++++ 5 files changed, 232 insertions(+), 43 deletions(-) create mode 100644 model-api/src/jsTest/kotlin/org/modelix/model/api/MapWithRoleKeyTest.kt diff --git a/model-api/src/commonMain/kotlin/org/modelix/model/api/IRole.kt b/model-api/src/commonMain/kotlin/org/modelix/model/api/IRole.kt index c9fa07e0a5..9693499682 100644 --- a/model-api/src/commonMain/kotlin/org/modelix/model/api/IRole.kt +++ b/model-api/src/commonMain/kotlin/org/modelix/model/api/IRole.kt @@ -138,6 +138,22 @@ interface IRoleReferenceFactory { fun fromIdAndName(id: String?, name: String?): E fun fromLegacyApi(value: String?): E = IRoleReference.decodeStringFromLegacyApi(value, this) fun fromString(value: String?): E = fromLegacyApi(value) + + /** + * Convert a role object, string representation, or legacy role interface into this reference type. + * Handles IRoleReference objects, IRoleDefinition objects, and string representations uniformly. + * @param value can be a String, IRoleReference, IRoleDefinition, or null + * @return converted reference + */ + fun fromRoleOrString(value: Any?): E { + return when (value) { + is String -> fromString(value) + is IRoleReference -> value as? E ?: fromString(value.stringForLegacyApi()) + is IRoleDefinition -> fromRoleOrString(value.toReference()) + null -> fromNull() + else -> error("Unsupported role type: ${value::class}") + } + } } fun IRoleReference.matches(other: IRoleReference): Boolean { diff --git a/model-api/src/commonTest/kotlin/ReferenceSerializationTests.kt b/model-api/src/commonTest/kotlin/ReferenceSerializationTests.kt index 471540cce4..9d96615e7b 100644 --- a/model-api/src/commonTest/kotlin/ReferenceSerializationTests.kt +++ b/model-api/src/commonTest/kotlin/ReferenceSerializationTests.kt @@ -1,4 +1,7 @@ +import org.modelix.model.api.IReferenceLinkReference +import org.modelix.model.api.IRoleReference import org.modelix.model.api.PNodeReference +import org.modelix.model.api.UnclassifiedReferenceLinkReference import kotlin.test.Test import kotlin.test.assertEquals @@ -19,4 +22,16 @@ class ReferenceSerializationTests { PNodeReference.deserialize("modelix:2bfd9f5e-95d0-11ee-b9d1-0242ac120002/abcd1234"), ) } + + @Test + fun decodeUnclassifiedReferenceLinkReference() { + val unclassified = UnclassifiedReferenceLinkReference( + "2bca1aa3-c113-4542-8ac2-2a6a30636981/6006699537885399164/6006699537885399165", + ) + + assertEquals( + IRoleReference.decodeStringFromLegacyApi(unclassified.stringForLegacyApi(), IReferenceLinkReference), + IReferenceLinkReference.fromId("2bca1aa3-c113-4542-8ac2-2a6a30636981/6006699537885399164/6006699537885399165"), + ) + } } diff --git a/model-api/src/jsMain/kotlin/org/modelix/model/api/MapWithRoleKey.kt b/model-api/src/jsMain/kotlin/org/modelix/model/api/MapWithRoleKey.kt index c2d2a3a206..6b3c236f1a 100644 --- a/model-api/src/jsMain/kotlin/org/modelix/model/api/MapWithRoleKey.kt +++ b/model-api/src/jsMain/kotlin/org/modelix/model/api/MapWithRoleKey.kt @@ -1,10 +1,12 @@ package org.modelix.model.api +import ReferenceRole + @JsExport sealed class MapWithRoleKey(private val type: IRoleReferenceFactory<*>) { private val entries = ArrayList>() - private fun get(role: IRoleReference): V? { + private fun _get(role: IRoleReference): V? { val index = entries.indexOfFirst { it.first.matches(role) } if (index < 0) return null @@ -15,7 +17,7 @@ sealed class MapWithRoleKey(private val type: IRoleReferenceFactory<*>) return entries[index].second } - private fun put(role: IRoleReference, value: V) { + private fun _put(role: IRoleReference, value: V) { val index = entries.indexOfFirst { it.first.matches(role) } if (index >= 0) { entries[index] = role.merge(entries[index].first) to value @@ -24,17 +26,17 @@ sealed class MapWithRoleKey(private val type: IRoleReferenceFactory<*>) } } - fun put(roleString: String, value: V) { - put(type.fromString(roleString), value) + fun put(role: ReferenceRole, value: V) { + _put(type.fromRoleOrString(role), value) } - fun get(roleString: String): V? { - return get(type.fromString(roleString)) + fun get(role: ReferenceRole): V? { + return _get(type.fromRoleOrString(role)) } - fun getOrPut(roleString: String, initializer: () -> V): V { - val role = type.fromString(roleString) - return get(role) ?: initializer().also { put(role.merge(role), it) } + fun getOrPut(role: ReferenceRole, initializer: () -> V): V { + val convertedRole = type.fromRoleOrString(role) + return _get(convertedRole) ?: initializer().also { _put(convertedRole.merge(convertedRole), it) } } } diff --git a/model-api/src/jsMain/kotlin/org/modelix/model/api/NodeAdapterJS.kt b/model-api/src/jsMain/kotlin/org/modelix/model/api/NodeAdapterJS.kt index 9a1b2f569b..5785f02ed4 100644 --- a/model-api/src/jsMain/kotlin/org/modelix/model/api/NodeAdapterJS.kt +++ b/model-api/src/jsMain/kotlin/org/modelix/model/api/NodeAdapterJS.kt @@ -74,7 +74,7 @@ class NodeAdapterJS(val node: INode) : INodeJS_ { override fun getParent(): INodeJS? = node.parent?.let { NodeAdapterJS(it) } override fun getChildren(role: ChildRole?): Array { - return node.asReadableNode().getChildren(toChildLink(role)).map { NodeAdapterJS(it.asLegacyNode()) }.toTypedArray() + return node.asReadableNode().getChildren(IChildLinkReference.fromRoleOrString(role)).map { NodeAdapterJS(it.asLegacyNode()) }.toTypedArray() } override fun getAllChildren(): Array { @@ -82,12 +82,12 @@ class NodeAdapterJS(val node: INode) : INodeJS_ { } override fun moveChild(role: ChildRole?, index: Number, child: INodeJS) { - node.asWritableNode().moveChild(toChildLink(role), index.toInt(), (child as NodeAdapterJS).node.asWritableNode()) + node.asWritableNode().moveChild(IChildLinkReference.fromRoleOrString(role), index.toInt(), (child as NodeAdapterJS).node.asWritableNode()) } override fun addNewChild(role: ChildRole?, index: Number, concept: IConceptJS?): INodeJS { val conceptRef = concept?.getUID()?.let { ConceptReference(it) } ?: NullConcept.getReference() - return node.asWritableNode().addNewChild(toChildLink(role), index.toInt(), conceptRef) + return node.asWritableNode().addNewChild(IChildLinkReference.fromRoleOrString(role), index.toInt(), conceptRef) .let { NodeAdapterJS(it.asLegacyNode()) } } @@ -104,22 +104,22 @@ class NodeAdapterJS(val node: INode) : INodeJS_ { } override fun getReferenceTargetNode(role: ReferenceRole): INodeJS? { - return node.asReadableNode().getReferenceTarget(toReferenceLink(role)) + return node.asReadableNode().getReferenceTarget(IReferenceLinkReference.fromRoleOrString(role)) ?.let { NodeAdapterJS(it.asLegacyNode()) } } override fun getReferenceTargetRef(role: ReferenceRole): INodeReferenceJS? { - return node.asReadableNode().getReferenceTargetRef(toReferenceLink(role))?.serialize() + return node.asReadableNode().getReferenceTargetRef(IReferenceLinkReference.fromRoleOrString(role))?.serialize() } override fun setReferenceTargetNode(role: ReferenceRole, target: INodeJS?) { val unwrappedTarget = if (target == null) null else (target as NodeAdapterJS).node - node.asWritableNode().setReferenceTarget(toReferenceLink(role), unwrappedTarget?.asWritableNode()) + node.asWritableNode().setReferenceTarget(IReferenceLinkReference.fromRoleOrString(role), unwrappedTarget?.asWritableNode()) } override fun setReferenceTargetRef(role: ReferenceRole, target: INodeReferenceJS?) { node.asWritableNode().setReferenceTargetRef( - toReferenceLink(role), + IReferenceLinkReference.fromRoleOrString(role), target ?.let { INodeReferenceSerializer.deserialize(it as String) }, ) @@ -130,11 +130,11 @@ class NodeAdapterJS(val node: INode) : INodeJS_ { } override fun getPropertyValue(role: PropertyRole): String? { - return node.asReadableNode().getPropertyValue(toPropertyReference(role)) ?: undefined + return node.asReadableNode().getPropertyValue(IPropertyReference.fromRoleOrString(role)) ?: undefined } override fun setPropertyValue(role: PropertyRole, value: String?) { - node.asWritableNode().setPropertyValue(toPropertyReference(role), value) + node.asWritableNode().setPropertyValue(IPropertyReference.fromRoleOrString(role), value) } override fun equals(other: Any?): Boolean { @@ -151,29 +151,4 @@ class NodeAdapterJS(val node: INode) : INodeJS_ { override fun hashCode(): Int { return node.hashCode() } - - private fun toPropertyReference(role: PropertyRole): IPropertyReference = when (role) { - is String -> IPropertyReference.fromString(role) - is IPropertyReference -> role - is IProperty -> role.toReference() - is IPropertyDefinition -> role.toReference() - else -> error("Not a property role: $role") - } - - private fun toReferenceLink(role: ReferenceRole): IReferenceLinkReference = when (role) { - is String -> IReferenceLinkReference.fromString(role) - is IReferenceLinkReference -> role - is IReferenceLink -> role.toReference() - is IReferenceLinkDefinition -> role.toReference() - else -> error("Not a reference role: $role") - } - - private fun toChildLink(role: ChildRole): IChildLinkReference = when (role) { - is String -> IChildLinkReference.fromString(role) - is IChildLinkReference -> role - is IChildLink -> role.toReference() - is IChildLinkDefinition -> role.toReference() - null -> NullChildLinkReference - else -> error("Not a child role: $role") - } } diff --git a/model-api/src/jsTest/kotlin/org/modelix/model/api/MapWithRoleKeyTest.kt b/model-api/src/jsTest/kotlin/org/modelix/model/api/MapWithRoleKeyTest.kt new file mode 100644 index 0000000000..d86a06f8bf --- /dev/null +++ b/model-api/src/jsTest/kotlin/org/modelix/model/api/MapWithRoleKeyTest.kt @@ -0,0 +1,181 @@ +package org.modelix.model.api + +import org.modelix.model.ModelFacade +import kotlin.test.Test +import kotlin.test.assertEquals + +class MapWithRoleKeyTest { + @Test + fun referenceLinkMapCachesValueByRoleObject() { + println("\r\n # referenceLinkMapCachesValueByRoleObject") + val branch = ModelFacade.toLocalBranch(ModelFacade.newLocalTree()) + val (source, target) = branch.computeWrite { + val root = branch.getRootNode() + val source = root.addNewChild("source") + val target = root.addNewChild("target") + source.setReferenceTarget(IReferenceLinkReference.fromId("testRef").toLegacy(), target) + source to target + } + + val jsTarget = NodeAdapterJS(target) + val roleObject = NodeAdapterJS(source).getReferenceRoles().first() + val map = MapWithReferenceRoleKey() + + val first = map.getOrPut(roleObject) { jsTarget } + assertEquals(jsTarget, first) + + val second = map.getOrPut(roleObject) { NodeAdapterJS(target) } + assertEquals(jsTarget, second) + } + + @Test + fun IReferenceLinkReference_stringForLegacyApi() { + val aNode = ModelFacade.toLocalBranch(ModelFacade.newLocalTree()).getRootNode() + assertEquals( + IReferenceLinkReference.fromIdAndName("refId", "refName").toLegacy().key(aNode), + ":refId:refName", + ) + } + + @Test + fun mapFindsValueAcrossRoleRepresentations() { + println("\r\n # mapFindsValueAcrossRoleRepresentations") + val branch = ModelFacade.toLocalBranch(ModelFacade.newLocalTree()) + val (source, target) = branch.computeWrite { + val root = branch.getRootNode() + val source = root.addNewChild("source") + val target = root.addNewChild("target") + source.setReferenceTarget(IReferenceLinkReference.fromIdAndName("refId", "refName").toLegacy(), target) + source to target + } + + println("source is a ${source::class}") + val jsTarget = NodeAdapterJS(target) + val roleObject = NodeAdapterJS(source).getReferenceRoles().first() + val map = MapWithReferenceRoleKey() + + map.getOrPut(roleObject) { jsTarget } + + // Once we've looked it up with a combined string, the entry remembers both as keys + assertEquals(jsTarget, map.getOrPut(":refId:refName") { error("Miss with combined string") }) + + // All these lookups should hit the same cached value + assertEquals(jsTarget, map.getOrPut(IReferenceLinkReference.fromId("refId")) { error("Miss with id") }) + assertEquals(jsTarget, map.getOrPut(IReferenceLinkReference.fromName("refName")) { error("Miss with name") }) + } + + private fun setupReferencingNodes(usesRoleIds: Boolean = false): Triple { + val branch = ModelFacade.toLocalBranch(ModelFacade.newLocalTree(usesRoleIds)) + return branch.computeWrite { + val root = branch.getRootNode() + val source = root.addNewChild("source") + val target = root.addNewChild("target") + source.setReferenceTarget(IReferenceLinkReference.fromIdAndName("refId", "refName").toLegacy(), target) + Triple(source, target, root.addNewChild("another")) + } + } + + @Test + fun usingRoleIds_lookup_by_name_only_misses() { + println("\r\n # usingRoleIds_lookup_by_name_only_misses") + // given I have some nodes + val (source, target, another) = setupReferencingNodes(usesRoleIds = true) + + // and the cache has warmed up + val jsTarget = NodeAdapterJS(target) + val roleObject = NodeAdapterJS(source).getReferenceRoles().first() + val map = MapWithReferenceRoleKey() + map.getOrPut(roleObject) { jsTarget } + + // then I still miss with a name-only lookup + assertEquals(another, map.getOrPut(IReferenceLinkReference.fromName("refName")) { NodeAdapterJS(another) }.node) + } + + @Test + fun usingRoleIds_lookup_with_id_only_hits() { + println("\r\n # usingRoleIds_lookup_with_id_only_hits") + // given I have some nodes + val (source, target, another) = setupReferencingNodes(usesRoleIds = true) + + // and the cache has warmed up + val jsTarget = NodeAdapterJS(target) + val roleObject = NodeAdapterJS(source).getReferenceRoles().first() + val map = MapWithReferenceRoleKey() + map.getOrPut(roleObject) { jsTarget } + + // then I hit the cache with an id-only lookup + assertEquals(target, map.getOrPut(IReferenceLinkReference.fromId("refId")) { error("Miss with id") }.node) + } + + @Test + fun notUsingRoleIds_lookup_by_id_only_misses() { + println("\r\n # notUsingRoleIds_lookup_by_id_only_misses") + // given I have some nodes + val (source, target, another) = setupReferencingNodes(usesRoleIds = false) + + // and the cache has warmed up + val jsTarget = NodeAdapterJS(target) + val roleObject = NodeAdapterJS(source).getReferenceRoles().first() + val map = MapWithReferenceRoleKey() + map.getOrPut(roleObject) { jsTarget } + + // then I still miss with an id-only lookup + assertEquals(another, map.getOrPut(IReferenceLinkReference.fromId("refId")) { NodeAdapterJS(another) }.node) + } + + @Test + fun notUsingRoleIds_lookup_with_name_only_hits() { + println("\r\n # notUsingRoleIds_lookup_with_name_only_hits") + // given I have some nodes + val (source, target, another) = setupReferencingNodes(usesRoleIds = false) + + // and the cache has warmed up + val jsTarget = NodeAdapterJS(target) + val roleObject = NodeAdapterJS(source).getReferenceRoles().first() + val map = MapWithReferenceRoleKey() + map.getOrPut(roleObject) { jsTarget } + + // then I hit the cache with a name-only lookup + assertEquals(target, map.getOrPut(IReferenceLinkReference.fromName("refName")) { error("Miss with name") }.node) + } + + @Test + fun childLinkMapCachesValueByRoleObject() { + println("\r\n # childLinkMapCachesValueByRoleObject") + val branch = ModelFacade.toLocalBranch(ModelFacade.newLocalTree()) + val child = branch.computeWrite { + branch.getRootNode().addNewChild("children") + } + + val roleObject = NodeAdapterJS(child).getRoleInParent() ?: error("No role") + val map = MapWithChildRoleKey() + + val first = map.getOrPut(roleObject) { "cached" } + assertEquals("cached", first) + + val second = map.getOrPut(roleObject) { "uncached" } + assertEquals("cached", second) + } + + @Test + fun propertyMapFindsCachedValueByStringAndRoleObject() { + println("\r\n # propertyMapFindsCachedValueByStringAndRoleObject") + val branch = ModelFacade.toLocalBranch(ModelFacade.newLocalTree()) + val node = branch.computeWrite { + val n = branch.getRootNode() + n.setPropertyValue("name", "testValue") + n + } + + val jsNode = NodeAdapterJS(node) + val roleObject = jsNode.getPropertyRoles().first() + val map = MapWithPropertyRoleKey() + + val stored = map.getOrPut(roleObject) { "stored" } + assertEquals("stored", stored) + + // Lookup by string should find the same cached value + val found = map.getOrPut("name") { "uncached" } + assertEquals("stored", found) + } +} From 035cb2debc5cab2c846dddb1672b7edcb6098757 Mon Sep 17 00:00:00 2001 From: Bastian Kruck Date: Thu, 5 Feb 2026 17:31:51 +0100 Subject: [PATCH 2/2] 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, ); }