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 ed8e35236d..7cfa39396d 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 @@ -8,6 +8,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.await import kotlinx.coroutines.promise +import kotlinx.datetime.toJSDate import org.modelix.datastructures.model.IGenericModelTree import org.modelix.model.TreeId import org.modelix.model.api.INode @@ -15,11 +16,13 @@ import org.modelix.model.api.INodeReference import org.modelix.model.api.JSNodeConverter import org.modelix.model.client.IdGenerator import org.modelix.model.data.ModelData +import org.modelix.model.lazy.CLVersion import org.modelix.model.lazy.RepositoryId import org.modelix.model.lazy.createObjectStoreCache import org.modelix.model.mutable.DummyIdGenerator import org.modelix.model.mutable.INodeIdGenerator import org.modelix.model.mutable.ModelixIdGenerator +import org.modelix.model.mutable.VersionedModelTree import org.modelix.model.mutable.asMutableThreadSafe import org.modelix.model.mutable.load import org.modelix.model.mutable.withAutoTransactions @@ -80,6 +83,9 @@ sealed class IdSchemeJS() { object READONLY : IdSchemeJS() } +@JsExport +data class VersionInformationWithModelTree(val version: VersionInformationJS, val tree: MutableModelTreeJs) + /** * JS-API for [ModelClientV2]. * Can be used to perform operations on the model server and to read and write model data. @@ -111,6 +117,8 @@ interface ClientJS { */ fun initRepository(repositoryId: String, useRoleIds: Boolean = true): Promise + fun loadReadonlyVersion(repositoryId: String, versionHash: String): Promise + /** * Fetch existing branches for a given repository from the model server. * @@ -192,6 +200,20 @@ internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS { } } + override fun loadReadonlyVersion(repositoryId: String, versionHash: String): Promise { + return GlobalScope.promise { + val version = modelClient.loadVersion(RepositoryId(repositoryId), versionHash, null) + VersionInformationWithModelTree( + VersionInformationJS( + (version as CLVersion).author, + version.getTimestamp()?.toJSDate(), + version.getContentHash(), + ), + MutableModelTreeJsImpl(VersionedModelTree(version).withAutoTransactions()), + ) + } + } + override fun dispose() { modelClient.close() } diff --git a/vue-model-api/src/index.ts b/vue-model-api/src/index.ts index 28336a2731..2e1eaa65c8 100644 --- a/vue-model-api/src/index.ts +++ b/vue-model-api/src/index.ts @@ -1,3 +1,4 @@ export { useModelsFromJson } from "./useModelsFromJson"; export { useModelClient } from "./useModelClient"; export { useReplicatedModel } from "./useReplicatedModel"; +export { useReadonlyVersion } from "./useReadonlyVersion"; diff --git a/vue-model-api/src/useReadonlyVersion.test.ts b/vue-model-api/src/useReadonlyVersion.test.ts new file mode 100644 index 0000000000..8bd3e43709 --- /dev/null +++ b/vue-model-api/src/useReadonlyVersion.test.ts @@ -0,0 +1,137 @@ +import { org } from "@modelix/model-client"; +import { toRoleJS } from "@modelix/ts-model-api"; +import { watchEffect, type Ref } from "vue"; +import { useModelClient } from "./useModelClient"; +import { useReadonlyVersion } from "./useReadonlyVersion"; +const { loadModelsFromJson } = org.modelix.model.client2; + +type VersionInformationWithModelTree = + org.modelix.model.client2.VersionInformationWithModelTree; +type MutableModelTreeJs = org.modelix.model.client2.MutableModelTreeJs; +type ClientJS = org.modelix.model.client2.ClientJS; +type VersionInformationJS = org.modelix.model.client2.VersionInformationJS; + +class SuccessfulVersionInformationWithModelTree { + public version: VersionInformationJS; + public tree: MutableModelTreeJs; + + constructor(versionHash: string) { + const modelData = { + root: {}, + }; + + const rootNode = loadModelsFromJson([JSON.stringify(modelData)]); + rootNode.setPropertyValue(toRoleJS("versionHash"), versionHash); + this.tree = { + rootNode, + addListener: jest.fn(), + } as unknown as MutableModelTreeJs; + this.version = { + author: "basti", + time: new Date(), + versionHash: "SQnQb*ZS1Vi8Mss2HAu0ENbcpstmqb5E6TuZvpSH1TW4", + } as unknown as VersionInformationJS; + } + + addListener = jest.fn(); +} + +test("test version is loaded", (done) => { + class SuccessfulClientJS { + loadReadonlyVersion( + _repositoryId: string, + _versionHash: string, + ): Promise { + return Promise.resolve( + new SuccessfulVersionInformationWithModelTree( + _versionHash, + ) as unknown as VersionInformationWithModelTree, + ); + } + + dispose = jest.fn(); + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), + ); + const { rootNode, versionInformation } = useReadonlyVersion( + client, + "aRepository", + "aVersionHash", + ); + watchEffect(() => { + if (rootNode.value !== null && versionInformation.value !== null) { + expect(rootNode.value.getPropertyValue(toRoleJS("versionHash"))).toBe( + "aVersionHash", + ); + done(); + } + }); +}); + +test("test connection error is exposed", (done) => { + class FailingClientJS { + loadReadonlyVersion( + _repositoryId: string, + _versionHash: string, + ): Promise { + return Promise.reject("Could not connect."); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new FailingClientJS() as unknown as ClientJS), + ); + + const { error } = useReadonlyVersion(client, "aRepository", "aVersionHash"); + + watchEffect(() => { + if (error.value !== null) { + expect(error.value).toBe("Could not connect."); + done(); + } + }); +}); + +describe("does not try loading", () => { + const loadReadonlyVersion = jest.fn((repositoryId, versionHash) => + Promise.resolve( + new SuccessfulVersionInformationWithModelTree( + versionHash, + ) as unknown as VersionInformationWithModelTree, + ), + ); + + let client: Ref; + class MockClientJS { + loadReadonlyVersion( + _repositoryId: string, + _versionHash: string, + ): Promise { + return loadReadonlyVersion(_repositoryId, _versionHash); + } + } + + beforeEach(() => { + jest.clearAllMocks(); + client = useModelClient("anURL", () => + Promise.resolve(new MockClientJS() as unknown as ClientJS), + ).client; + }); + + test("if client is undefined", () => { + useReadonlyVersion(undefined, "aRepository", "aVersionHash"); + expect(loadReadonlyVersion).not.toHaveBeenCalled(); + }); + + test("if repositoryId is undefined", () => { + useReadonlyVersion(client, undefined, "aVersionHash"); + expect(loadReadonlyVersion).not.toHaveBeenCalled(); + }); + + test("if branchId is undefined", () => { + useReadonlyVersion(client, "aRepository", undefined); + expect(loadReadonlyVersion).not.toHaveBeenCalled(); + }); +}); diff --git a/vue-model-api/src/useReadonlyVersion.ts b/vue-model-api/src/useReadonlyVersion.ts new file mode 100644 index 0000000000..4059eb895e --- /dev/null +++ b/vue-model-api/src/useReadonlyVersion.ts @@ -0,0 +1,94 @@ +import type { org } from "@modelix/model-client"; +import type { INodeJS } from "@modelix/ts-model-api"; +import { useLastPromiseEffect } from "./internal/useLastPromiseEffect"; +import type { MaybeRefOrGetter, Ref } from "vue"; +import { shallowRef, toValue } from "vue"; +import type { ReactiveINodeJS } from "./internal/ReactiveINodeJS"; +import { toReactiveINodeJS } from "./internal/ReactiveINodeJS"; +import { Cache } from "./internal/Cache"; +import { handleChange } from "./internal/handleChange"; + +type ClientJS = org.modelix.model.client2.ClientJS; +type ChangeJS = org.modelix.model.client2.ChangeJS; +type VersionInformationJS = org.modelix.model.client2.VersionInformationJS; + +function isDefined(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} + +/** + * + * */ +export function useReadonlyVersion( + client: MaybeRefOrGetter, + repositoryId: MaybeRefOrGetter, + versionHash: MaybeRefOrGetter, +): { + versionInformation: Ref; + rootNode: Ref; + dispose: () => void; + error: Ref; +} { + const versionInformationRef: Ref = + shallowRef(null); + const rootNodeRef: Ref = shallowRef(null); + const errorRef: Ref = shallowRef(null); + + const dispose = () => { + versionInformationRef.value = null; + rootNodeRef.value = null; + errorRef.value = null; + }; + + useLastPromiseEffect( + () => { + dispose(); + const clientValue = toValue(client); + if (!isDefined(clientValue)) { + return; + } + const repositoryIdValue = toValue(repositoryId); + if (!isDefined(repositoryIdValue)) { + return; + } + const versionHashValue = toValue(versionHash); + if (!isDefined(versionHashValue)) { + return; + } + const cache = new Cache(); + return clientValue + .loadReadonlyVersion(repositoryIdValue, versionHashValue) + .then(({ version, tree }) => ({ + versionInfo: version, + tree, + cache, + })); + }, + ({ versionInfo, tree, cache }, isResultOfLastStartedPromise) => { + if (isResultOfLastStartedPromise) { + tree.addListener((change: ChangeJS) => { + if (cache === null) { + throw Error("The cache is unexpectedly not set up."); + } + handleChange(change, cache); + }); + const unreactiveRootNode = tree.rootNode; + const reactiveRootNode = toReactiveINodeJS(unreactiveRootNode, cache); + versionInformationRef.value = versionInfo; + rootNodeRef.value = reactiveRootNode; + } + }, + (reason, isResultOfLastStartedPromise) => { + if (isResultOfLastStartedPromise) { + errorRef.value = reason; + } + }, + ); + + return { + versionInformation: versionInformationRef, + rootNode: rootNodeRef, + dispose, + error: errorRef, + }; +}