diff --git a/build.gradle.kts b/build.gradle.kts index 7e5b8dbcd3..b1be3bd9ee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,7 @@ plugins { alias(libs.plugins.detekt) alias(libs.plugins.kotlinx.kover) alias(libs.plugins.npm.publish) apply false + id("org.jetbrains.gradle.plugin.idea-ext") version "1.1.9" apply false } group = "org.modelix" diff --git a/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt b/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt index f203bbd87e..5a60bb8cae 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt @@ -46,3 +46,17 @@ fun runWithNettyServer( nettyServer.stop() } } + +fun runTestApplication(block: suspend ApplicationTestBuilder.() -> Unit) { + val previousDevMode = System.getProperty("io.ktor.development") + System.setProperty("io.ktor.development", "false") + try { + io.ktor.server.testing.testApplication(block) + } finally { + if (previousDevMode == null) { + System.clearProperty("io.ktor.development") + } else { + System.setProperty("io.ktor.development", previousDevMode) + } + } +} diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt index 36fac58d11..a4ca479b24 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt @@ -17,7 +17,6 @@ import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode import io.ktor.server.testing.ApplicationTestBuilder -import io.ktor.server.testing.testApplication import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.every @@ -42,6 +41,7 @@ import org.modelix.model.persistent.CPNode import org.modelix.model.persistent.CPNodeRef import org.modelix.model.server.handlers.RepositoriesManager import org.modelix.model.server.installDefaultServerPlugins +import org.modelix.model.server.runTestApplication import kotlin.test.BeforeTest import kotlin.test.Test @@ -308,7 +308,7 @@ class DiffViewTest { val v1 = createCLVersion { it } val v2 = createCLVersion(v1) { it } - private fun runDiffViewTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + private fun runDiffViewTest(block: suspend ApplicationTestBuilder.() -> Unit) = runTestApplication { application { installDefaultServerPlugins() DiffView(repositoriesManager).init(this) diff --git a/vue-model-api/src/index.ts b/vue-model-api/src/index.ts index 2e1eaa65c8..1078102bd1 100644 --- a/vue-model-api/src/index.ts +++ b/vue-model-api/src/index.ts @@ -1,4 +1,5 @@ export { useModelsFromJson } from "./useModelsFromJson"; export { useModelClient } from "./useModelClient"; +export { useReplicatedModels } from "./useReplicatedModels"; export { useReplicatedModel } from "./useReplicatedModel"; export { useReadonlyVersion } from "./useReadonlyVersion"; diff --git a/vue-model-api/src/useReplicatedModel.test.ts b/vue-model-api/src/useReplicatedModel.test.ts index 2e458b5bad..90a05bed0f 100644 --- a/vue-model-api/src/useReplicatedModel.test.ts +++ b/vue-model-api/src/useReplicatedModel.test.ts @@ -1,67 +1,56 @@ import { org } from "@modelix/model-client"; -import type { INodeJS } from "@modelix/ts-model-api"; import { toRoleJS } from "@modelix/ts-model-api"; -import { watchEffect, type Ref, ref } from "vue"; +import { watchEffect } from "vue"; import { useModelClient } from "./useModelClient"; -import { useReplicatedModel } from "./useReplicatedModel"; +import { useReplicatedModel } from "./useReplicatedModels"; import IdSchemeJS = org.modelix.model.client2.IdSchemeJS; -type BranchJS = org.modelix.model.client2.MutableModelTreeJs; -type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; type ClientJS = org.modelix.model.client2.ClientJS; +type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; const { loadModelsFromJson } = org.modelix.model.client2; -class SuccessfulBranchJS { - public rootNode: INodeJS; - - constructor(branchId: string) { - const root = { - root: {}, - }; - - this.rootNode = loadModelsFromJson([JSON.stringify(root)]); - this.rootNode.setPropertyValue(toRoleJS("branchId"), branchId); - } - - addListener = jest.fn(); -} - -class SuccessfulReplicatedModelJS { - private branch: BranchJS; - constructor(branchId: string) { - this.branch = new SuccessfulBranchJS(branchId) as unknown as BranchJS; - } - - getBranch() { - return this.branch; - } - dispose = jest.fn(); -} +import ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; -test("test branch connects", (done) => { +test("test wrapper backwards compatibility", (done) => { class SuccessfulClientJS { - startReplicatedModel( - _repositoryId: string, - branchId: string, + startReplicatedModels( + parameters: ReplicatedModelParameters[], ): Promise { - return Promise.resolve( - new SuccessfulReplicatedModelJS( - branchId, - ) as unknown as ReplicatedModelJS, - ); + // 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); + + const branch = { + rootNode, + getRootNodes: () => [rootNode], + addListener: jest.fn(), + removeListener: jest.fn(), + resolveNode: jest.fn(), + }; + + const replicatedModel = { + getBranch: () => branch, + dispose: jest.fn(), + getCurrentVersionInformation: jest.fn(), + } as unknown as ReplicatedModelJS; + + return Promise.resolve(replicatedModel); } } const { client } = useModelClient("anURL", () => Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), ); + const { rootNode, replicatedModel } = useReplicatedModel( client, "aRepository", "aBranch", IdSchemeJS.MODELIX, ); + watchEffect(() => { if (rootNode.value !== null && replicatedModel.value !== null) { expect(rootNode.value.getPropertyValue(toRoleJS("branchId"))).toBe( @@ -71,99 +60,3 @@ test("test branch connects", (done) => { } }); }); - -test("test branch connection error is exposed", (done) => { - class FailingClientJS { - startReplicatedModel( - _repositoryId: string, - _branchId: string, - ): Promise { - return Promise.reject("Could not connect branch."); - } - } - - const { client } = useModelClient("anURL", () => - Promise.resolve(new FailingClientJS() as unknown as ClientJS), - ); - - const { error } = useReplicatedModel( - client, - "aRepository", - "aBranch", - IdSchemeJS.MODELIX, - ); - - watchEffect(() => { - if (error.value !== null) { - expect(error.value).toBe("Could not connect branch."); - done(); - } - }); -}); - -describe("does not start model", () => { - const startReplicatedModel = jest.fn((repositoryId, branchId) => - Promise.resolve( - new SuccessfulReplicatedModelJS(branchId) as unknown as ReplicatedModelJS, - ), - ); - - let client: Ref; - class MockClientJS { - startReplicatedModel( - _repositoryId: string, - _branchId: string, - ): Promise { - return startReplicatedModel(_repositoryId, _branchId); - } - } - - beforeEach(() => { - jest.clearAllMocks(); - client = useModelClient("anURL", () => - Promise.resolve(new MockClientJS() as unknown as ClientJS), - ).client; - }); - - test("if client is undefined", () => { - useReplicatedModel(undefined, "aRepository", "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if repositoryId is undefined", () => { - useReplicatedModel(client, undefined, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if branchId is undefined", () => { - useReplicatedModel(client, "aRepository", undefined, IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if idScheme is undefined", () => { - useReplicatedModel(client, "aRepository", "aBranch", undefined); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if repositoryId switches to another value", async () => { - const repositoryId = ref("aRepository"); - useReplicatedModel(client, repositoryId, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).toHaveBeenCalled(); - - startReplicatedModel.mockClear(); - repositoryId.value = "aNewValue"; - await new Promise(process.nextTick); - expect(startReplicatedModel).toHaveBeenCalled(); - }); - - test("if repositoryId switches to undefined", async () => { - const repositoryId = ref("aRepository"); - useReplicatedModel(client, repositoryId, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).toHaveBeenCalled(); - - startReplicatedModel.mockClear(); - repositoryId.value = undefined; - await new Promise(process.nextTick); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); -}); diff --git a/vue-model-api/src/useReplicatedModels.test.ts b/vue-model-api/src/useReplicatedModels.test.ts new file mode 100644 index 0000000000..92f8842380 --- /dev/null +++ b/vue-model-api/src/useReplicatedModels.test.ts @@ -0,0 +1,184 @@ +import { org } from "@modelix/model-client"; +import type { INodeJS } from "@modelix/ts-model-api"; +import { toRoleJS } from "@modelix/ts-model-api"; +import { watchEffect, type Ref, ref } from "vue"; +import { useModelClient } from "./useModelClient"; +import { useReplicatedModels } from "./useReplicatedModels"; +import IdSchemeJS = org.modelix.model.client2.IdSchemeJS; +import ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; + +type BranchJS = org.modelix.model.client2.MutableModelTreeJs; +type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; +type ClientJS = org.modelix.model.client2.ClientJS; + +const { loadModelsFromJson } = org.modelix.model.client2; + +class SuccessfulBranchJS { + public rootNode: INodeJS; + + constructor(branchId: string) { + const root = { + root: {}, + }; + + this.rootNode = loadModelsFromJson([JSON.stringify(root)]); + this.rootNode.setPropertyValue(toRoleJS("branchId"), branchId); + } + + getRootNodes() { + return [this.rootNode]; + } + + addListener = jest.fn(); +} + +class SuccessfulReplicatedModelJS { + private branch: BranchJS; + constructor(branchId: string) { + this.branch = new SuccessfulBranchJS(branchId) as unknown as BranchJS; + } + + getBranch() { + return this.branch; + } + dispose = jest.fn(); +} + +test("test branch connects", (done) => { + class SuccessfulClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + // For this test, we assume only one model is requested and we use its branchId + const branchId = parameters[0].branchId; + return Promise.resolve( + new SuccessfulReplicatedModelJS( + branchId, + ) as unknown as ReplicatedModelJS, + ); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), + ); + const { rootNodes, replicatedModel } = useReplicatedModels(client, [ + new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX), + ]); + watchEffect(() => { + if (rootNodes.value.length > 0 && replicatedModel.value !== null) { + expect(rootNodes.value[0].getPropertyValue(toRoleJS("branchId"))).toBe( + "aBranch", + ); + done(); + } + }); +}); + +test("test branch connection error is exposed", (done) => { + class FailingClientJS { + startReplicatedModels( + _parameters: ReplicatedModelParameters[], + ): Promise { + return Promise.reject("Could not connect branch."); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new FailingClientJS() as unknown as ClientJS), + ); + + const { error } = useReplicatedModels(client, [ + new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX), + ]); + + watchEffect(() => { + if (error.value !== null) { + expect(error.value).toBe("Could not connect branch."); + done(); + } + }); +}); + +describe("does not start model", () => { + const startReplicatedModels = jest.fn((parameters) => { + // Return a dummy replicated model + const branchId = parameters[0]?.branchId ?? "defaultBranch"; + return Promise.resolve( + new SuccessfulReplicatedModelJS(branchId) as unknown as ReplicatedModelJS, + ); + }); + + let client: Ref; + class MockClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + return startReplicatedModels(parameters); + } + } + + beforeEach(() => { + jest.clearAllMocks(); + client = useModelClient("anURL", () => + Promise.resolve(new MockClientJS() as unknown as ClientJS), + ).client; + }); + + test("if client is undefined", () => { + useReplicatedModels(undefined, [ + new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ), + ]); + expect(startReplicatedModels).not.toHaveBeenCalled(); + }); + + test("if models is undefined", () => { + useReplicatedModels(client, undefined); + expect(startReplicatedModels).not.toHaveBeenCalled(); + }); + + test("if models switches to another value", async () => { + const models = ref([ + new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ), + ]); + useReplicatedModels(client, models); + expect(startReplicatedModels).toHaveBeenCalled(); + + startReplicatedModels.mockClear(); + models.value = [ + new ReplicatedModelParameters( + "aNewRepository", + "aNewBranch", + IdSchemeJS.MODELIX, + ), + ]; + await new Promise(process.nextTick); + expect(startReplicatedModels).toHaveBeenCalled(); + }); + + test("if models switches to undefined", async () => { + const models = ref([ + new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ), + ]); + useReplicatedModels(client, models); + expect(startReplicatedModels).toHaveBeenCalled(); + + startReplicatedModels.mockClear(); + models.value = undefined; + await new Promise(process.nextTick); + // It should not call startReplicatedModels, but it might trigger dispose() + expect(startReplicatedModels).not.toHaveBeenCalled(); + }); +}); diff --git a/vue-model-api/src/useReplicatedModels.ts b/vue-model-api/src/useReplicatedModels.ts new file mode 100644 index 0000000000..aa0e89b37e --- /dev/null +++ b/vue-model-api/src/useReplicatedModels.ts @@ -0,0 +1,122 @@ +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 ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; +type ChangeJS = org.modelix.model.client2.ChangeJS; +type ReplicatedModelParameters = + org.modelix.model.client2.ReplicatedModelParameters; + +function isDefined(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} + +/** + * Creates replicated models for the given repositories and branches. + * A replicated model exposes a branch that can be used to read and write model data. + * The written model data is automatically synced to the model server. + * Changes from the model server are automatically synced to the branch in the replicated model. + * + * Also creates root nodes that use Vue's reactivity and can be used in Vue like reactive objects. + * Changes to model data trigger recalculation of computed properties or re-rendering of components using that data. + * + * Calling the returned dispose function stops syncing the root nodes to the underlying branches on the server. + * + * @param client - Reactive reference of a client to a model server. + * @param models - Reactive reference to an array of ReplicatedModelParameters. + * + * @returns {Object} values Wrapper around different returned values. + * @returns {Ref} values.replicatedModel Reactive reference to the replicated model for the specified branches. + * @returns {Ref} values.rootNodes Reactive reference to an array of root nodes with Vue.js reactivity for the specified branches. + * @returns {() => void} values.dispose A function to manually dispose the root nodes. + * @returns {Ref} values.error Reactive reference to a connection error. + */ +export function useReplicatedModels( + client: MaybeRefOrGetter, + models: MaybeRefOrGetter, +): { + replicatedModel: Ref; + rootNodes: Ref; + dispose: () => void; + error: Ref; +} { + // Use `replicatedModel` to access the replicated model without tracking overhead of Vue.js. + let replicatedModel: ReplicatedModelJS | null = null; + const replicatedModelRef: Ref = shallowRef(null); + const rootNodesRef: Ref = shallowRef([]); + const errorRef: Ref = shallowRef(null); + + const dispose = () => { + // Using `replicatedModelRef.value` here would create a circular dependency. + // `toRaw` does not work on `Ref<>`. + if (replicatedModel !== null) { + replicatedModel.dispose(); + } + replicatedModelRef.value = null; + rootNodesRef.value = []; + errorRef.value = null; + }; + + useLastPromiseEffect<{ + replicatedModel: ReplicatedModelJS; + cache: Cache; + }>( + () => { + dispose(); + const clientValue = toValue(client); + if (!isDefined(clientValue)) { + return; + } + const modelsValue = toValue(models); + if (!isDefined(modelsValue)) { + return; + } + const cache = new Cache(); + return clientValue + .startReplicatedModels(modelsValue) + .then((replicatedModel) => ({ replicatedModel, cache })); + }, + ( + { replicatedModel: connectedReplicatedModel, cache }, + isResultOfLastStartedPromise, + ) => { + if (isResultOfLastStartedPromise) { + replicatedModel = connectedReplicatedModel; + const branch = replicatedModel.getBranch(); + branch.addListener((change: ChangeJS) => { + if (cache === null) { + throw Error("The cache is unexpectedly not set up."); + } + handleChange(change, cache); + }); + const unreactiveRootNodes = branch.getRootNodes(); + const reactiveRootNodes = unreactiveRootNodes.map((node) => + toReactiveINodeJS(node, cache), + ); + replicatedModelRef.value = replicatedModel; + rootNodesRef.value = reactiveRootNodes; + } else { + connectedReplicatedModel.dispose(); + } + }, + (reason, isResultOfLastStartedPromise) => { + if (isResultOfLastStartedPromise) { + errorRef.value = reason; + } + }, + ); + + return { + replicatedModel: replicatedModelRef, + rootNodes: rootNodesRef, + dispose, + error: errorRef, + }; +}