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..1ced6e7db8 100644 --- a/vue-model-api/src/useReplicatedModel.test.ts +++ b/vue-model-api/src/useReplicatedModel.test.ts @@ -1,169 +1,224 @@ 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 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; +import ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; - constructor(branchId: string) { - const root = { - root: {}, - }; +test("test wrapper backwards compatibility", (done) => { + /* eslint-disable */ + class SuccessfulClientJS implements ClientJS { + startReplicatedModel( + repositoryId: string, + branchId: string, + idScheme: org.modelix.model.client2.IdSchemeJS, + ): Promise { + // Mock implementation that returns a dummy object with a branch + 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); + } - this.rootNode = loadModelsFromJson([JSON.stringify(root)]); - this.rootNode.setPropertyValue(toRoleJS("branchId"), branchId); - } + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + // 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); + } - addListener = jest.fn(); -} + readonly __doNotUseOrImplementIt: any; -class SuccessfulReplicatedModelJS { - private branch: BranchJS; - constructor(branchId: string) { - this.branch = new SuccessfulBranchJS(branchId) as unknown as BranchJS; - } + createBranch( + repositoryId: string, + branchId: string, + versionHash: string, + ): Promise { + throw Error("Not implemented"); + } - getBranch() { - return this.branch; - } - dispose = jest.fn(); -} + deleteBranch(repositoryId: string, branchId: string): Promise { + throw Error("Not implemented"); + } -test("test branch connects", (done) => { - class SuccessfulClientJS { - startReplicatedModel( - _repositoryId: string, - branchId: string, - ): Promise { - return Promise.resolve( - new SuccessfulReplicatedModelJS( - branchId, - ) as unknown as ReplicatedModelJS, - ); + diffAsMutationParameters( + repositoryId: string, + newVersion: string, + oldVersion: string, + ): Promise> { + throw Error("Not implemented"); } - } - 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( - "aBranch", - ); - done(); + dispose(): void { + throw Error("Not implemented"); } - }); -}); -test("test branch connection error is exposed", (done) => { - class FailingClientJS { - startReplicatedModel( - _repositoryId: string, - _branchId: string, - ): Promise { - return Promise.reject("Could not connect branch."); + fetchBranches(repositoryId: string): Promise> { + throw Error("Not implemented"); } - } - const { client } = useModelClient("anURL", () => - Promise.resolve(new FailingClientJS() as unknown as ClientJS), - ); + fetchBranchesWithHashes( + repositoryId: string, + ): Promise> { + throw Error("Not implemented"); + } - const { error } = useReplicatedModel( - client, - "aRepository", - "aBranch", - IdSchemeJS.MODELIX, - ); + fetchRepositories(): Promise> { + throw Error("Not implemented"); + } - watchEffect(() => { - if (error.value !== null) { - expect(error.value).toBe("Could not connect branch."); - done(); + getHistoryForFixedIntervals( + repositoryId: string, + headVersion: string, + intervalDurationSeconds: number, + skip: number, + limit: number, + ): Promise> { + throw Error("Not implemented"); } - }); -}); -describe("does not start model", () => { - const startReplicatedModel = jest.fn((repositoryId, branchId) => - Promise.resolve( - new SuccessfulReplicatedModelJS(branchId) as unknown as ReplicatedModelJS, - ), - ); + getHistoryForFixedIntervalsForBranch( + repositoryId: string, + branchId: string, + intervalDurationSeconds: number, + skip: number, + limit: number, + ): Promise> { + throw Error("Not implemented"); + } - let client: Ref; - class MockClientJS { - startReplicatedModel( - _repositoryId: string, - _branchId: string, - ): Promise { - return startReplicatedModel(_repositoryId, _branchId); + getHistoryForProvidedIntervals( + repositoryId: string, + headVersion: string, + splitAt: Array, + ): Promise> { + throw Error("Not implemented"); } - } - beforeEach(() => { - jest.clearAllMocks(); - client = useModelClient("anURL", () => - Promise.resolve(new MockClientJS() as unknown as ClientJS), - ).client; - }); + getHistoryForProvidedIntervalsForBranch( + repositoryId: string, + branchId: string, + splitAt: Array, + ): Promise> { + throw Error("Not implemented"); + } - test("if client is undefined", () => { - useReplicatedModel(undefined, "aRepository", "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); + getHistoryRange( + repositoryId: string, + headVersion: string, + skip: number, + limit: number, + ): Promise> { + throw Error("Not implemented"); + } - test("if repositoryId is undefined", () => { - useReplicatedModel(client, undefined, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); + getHistoryRangeForBranch( + repositoryId: string, + branchId: string, + skip: number, + limit: number, + ): Promise> { + throw Error("Not implemented"); + } - test("if branchId is undefined", () => { - useReplicatedModel(client, "aRepository", undefined, IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); + getHistorySessions( + repositoryId: string, + headVersion: string, + delaySeconds: number, + skip: number, + limit: number, + ): Promise> { + throw Error("Not implemented"); + } - test("if idScheme is undefined", () => { - useReplicatedModel(client, "aRepository", "aBranch", undefined); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); + getHistorySessionsForBranch( + repositoryId: string, + branchId: string, + delaySeconds: number, + skip: number, + limit: number, + ): Promise> { + throw Error("Not implemented"); + } - test("if repositoryId switches to another value", async () => { - const repositoryId = ref("aRepository"); - useReplicatedModel(client, repositoryId, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).toHaveBeenCalled(); + initRepository(repositoryId: string, useRoleIds?: boolean): Promise { + throw Error("Not implemented"); + } - startReplicatedModel.mockClear(); - repositoryId.value = "aNewValue"; - await new Promise(process.nextTick); - expect(startReplicatedModel).toHaveBeenCalled(); - }); + loadReadonlyVersion( + repositoryId: string, + versionHash: string, + ): Promise { + throw Error("Not implemented"); + } + + revertTo( + repositoryId: string, + branchId: string, + targetVersionHash: string, + ): Promise { + throw Error("Not implemented"); + } + + setClientProvidedUserId(userId: string): void {} + } - test("if repositoryId switches to undefined", async () => { - const repositoryId = ref("aRepository"); - useReplicatedModel(client, repositoryId, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).toHaveBeenCalled(); + const { client } = useModelClient("anURL", () => + Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), + ); - startReplicatedModel.mockClear(); - repositoryId.value = undefined; - await new Promise(process.nextTick); - expect(startReplicatedModel).not.toHaveBeenCalled(); + const { rootNode, replicatedModel } = useReplicatedModel( + client, + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ); + + watchEffect(() => { + if (rootNode.value !== null && replicatedModel.value !== null) { + expect(rootNode.value.getPropertyValue(toRoleJS("branchId"))).toBe( + "aBranch", + ); + done(); + } }); }); 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, + }; +}