diff --git a/lib/modules/asset/AssetService.ts b/lib/modules/asset/AssetService.ts index 7073f1ec..c413b3bd 100644 --- a/lib/modules/asset/AssetService.ts +++ b/lib/modules/asset/AssetService.ts @@ -1,26 +1,42 @@ -import _ from "lodash"; import { Backend, BadRequestError, PluginContext, User } from "kuzzle"; -import { JSONObject, KDocument, KHit, SearchResult } from "kuzzle-sdk"; +import { + BaseRequest, + JSONObject, + KDocument, + KHit, + SearchResult, + mReplaceResponse, +} from "kuzzle-sdk"; +import _ from "lodash"; -import { MeasureContent } from "../measure/"; import { AskDeviceUnlinkAsset } from "../device"; -import { EmbeddedMeasure, Metadata, lock, ask, flattenObject } from "../shared"; +import { MeasureContent } from "../measure/"; +import { AskModelAssetGet, AssetModelContent } from "../model"; import { + AskEngineList, DeviceManagerConfiguration, - InternalCollection, DeviceManagerPlugin, + InternalCollection, } from "../plugin"; -import { AskModelAssetGet } from "../model"; +import { + EmbeddedMeasure, + Metadata, + ask, + flattenObject, + lock, + onAsk, +} from "../shared"; -import { AssetContent } from "./types/AssetContent"; +import { AssetHistoryService } from "./AssetHistoryService"; +import { ApiAssetGetMeasuresResult } from "./exports"; import { AssetSerializer } from "./model/AssetSerializer"; +import { AssetContent } from "./types/AssetContent"; import { + AskAssetRefreshModel, EventAssetUpdateAfter, EventAssetUpdateBefore, } from "./types/AssetEvents"; -import { AssetHistoryService } from "./AssetHistoryService"; import { AssetHistoryEventMetadata } from "./types/AssetHistoryContent"; -import { ApiAssetGetMeasuresResult } from "./types/AssetApi"; export class AssetService { private context: PluginContext; @@ -52,6 +68,15 @@ export class AssetService { this.context = plugin.context; this.config = plugin.config; this.assetHistoryService = assetHistoryService; + + this.registerAskEvents(); + } + + registerAskEvents() { + onAsk( + "ask:device-manager:asset:refresh-model", + this.refreshModel.bind(this) + ); } /** @@ -306,6 +331,42 @@ export class AssetService { return result; } + /** + * Replace an asset metadata + */ + public async mReplaceAndHistorize( + engineId: string, + assets: KDocument[], + removedMetadata: string[], + { refresh }: { refresh: any } + ): Promise { + const replacedAssets = await this.sdk.document.mReplace( + engineId, + InternalCollection.ASSETS, + assets.map((asset) => ({ _id: asset._id, body: asset._source })), + { refresh, source: true } + ); + + await Promise.all( + replacedAssets.successes.map((asset) => + this.assetHistoryService.add( + engineId, + { + metadata: { + names: Object.keys(flattenObject(asset._source.metadata)).concat( + removedMetadata.map((name) => `-${name}`) + ), + }, + name: "metadata", + }, + asset as KDocument + ) + ) + ); + + return replacedAssets; + } + private async getEngine(engineId: string): Promise { const engine = await this.sdk.document.get( this.config.adminIndex, @@ -315,4 +376,76 @@ export class AssetService { return engine._source.engine; } + + private async refreshModel({ + assetModel, + }: { + assetModel: AssetModelContent; + }): Promise { + const engines = await ask("ask:device-manager:engine:list", { + group: assetModel.engineGroup, + }); + + const targets = engines.map((engine) => ({ + collections: [InternalCollection.ASSETS], + index: engine.index, + })); + + const assets = await this.sdk.query< + BaseRequest, + JSONObject // TODO: switch to DocumentSearchResult once KHit<> has index and collection properties + >({ + action: "search", + body: { query: { equals: { model: assetModel.asset.model } } }, + controller: "document", + lang: "koncorde", + targets, + }); + + const modelMetadata = {}; + + for (const metadataName of Object.keys(assetModel.asset.metadataMappings)) { + const defaultMetadata = assetModel.asset.defaultMetadata[metadataName]; + modelMetadata[metadataName] = defaultMetadata ?? null; + } + + const removedMetadata: string[] = []; + + const updatedAssetsPerIndex: Record[]> = + assets.result.hits.reduce( + (acc: Record[]>, asset: JSONObject) => { + const assetMetadata = { ...asset._source.metadata }; + + for (const key of Object.keys(asset._source.metadata)) { + if (!(key in modelMetadata)) { + removedMetadata.push(key); + delete assetMetadata[key]; + } + } + + asset._source.metadata = { + ...modelMetadata, + ...assetMetadata, + }; + + acc[asset.index].push(asset as KDocument); + + return acc; + }, + Object.fromEntries( + engines.map((engine) => [ + engine.index, + [] as KDocument[], + ]) + ) + ); + + await Promise.all( + Object.entries(updatedAssetsPerIndex).map(([index, updatedAssets]) => + this.mReplaceAndHistorize(index, updatedAssets, removedMetadata, { + refresh: "wait_for", + }) + ) + ); + } } diff --git a/lib/modules/asset/types/AssetEvents.ts b/lib/modules/asset/types/AssetEvents.ts index 6f351ed7..bb8015bc 100644 --- a/lib/modules/asset/types/AssetEvents.ts +++ b/lib/modules/asset/types/AssetEvents.ts @@ -1,4 +1,5 @@ import { KDocument } from "kuzzle-sdk"; +import { AssetModelContent } from "lib/modules/model"; import { Metadata } from "../../../modules/shared"; @@ -17,6 +18,16 @@ export type EventAssetUpdateAfter = { args: [{ asset: KDocument; metadata: Metadata }]; }; +export type AskAssetRefreshModel = { + name: "ask:device-manager:asset:refresh-model"; + + payload: { + assetModel: AssetModelContent; + }; + + result: void; +}; + /** * @internal */ diff --git a/lib/modules/model/ModelService.ts b/lib/modules/model/ModelService.ts index 65a2c7f3..41b4cbd4 100644 --- a/lib/modules/model/ModelService.ts +++ b/lib/modules/model/ModelService.ts @@ -14,14 +14,15 @@ import { } from "../plugin"; import { ask, onAsk } from "../shared/utils/ask"; +import { AskAssetRefreshModel } from "../asset"; +import { flattenObject } from "../shared/utils/flattenObject"; +import { ModelSerializer } from "./ModelSerializer"; import { AssetModelContent, DeviceModelContent, MeasureModelContent, } from "./types/ModelContent"; -import { ModelSerializer } from "./ModelSerializer"; import { AskModelAssetGet, AskModelDeviceGet } from "./types/ModelEvents"; -import { flattenObject } from "../shared/utils/flattenObject"; export class ModelService { private config: DeviceManagerConfiguration; @@ -90,7 +91,9 @@ export class ModelService { ); await ask("ask:device-manager:engine:updateAll"); - // @todo update assets in every engine to add the new metadata with null value or default metadata + await ask("ask:device-manager:asset:refresh-model", { + assetModel: modelContent, + }); return assetModel; } diff --git a/lib/modules/plugin/DeviceManagerEngine.ts b/lib/modules/plugin/DeviceManagerEngine.ts index fa2f9a62..9792bd2f 100644 --- a/lib/modules/plugin/DeviceManagerEngine.ts +++ b/lib/modules/plugin/DeviceManagerEngine.ts @@ -2,6 +2,7 @@ import _ from "lodash"; import { Backend, InternalError, Plugin } from "kuzzle"; import { JSONObject } from "kuzzle-sdk"; import { AbstractEngine, ConfigManager } from "kuzzle-plugin-commons"; +import { EngineContent } from "kuzzle-plugin-commons/lib/engine/EngineContent"; import { assetsMappings, assetsHistoryMappings } from "../asset"; import { @@ -23,6 +24,16 @@ const digitalTwinMappings = { device: devicesMappings, } as const; +export type AskEngineList = { + name: "ask:device-manager:engine:list"; + + payload: { + group: string | null; + }; + + result: EngineContent[]; +}; + export type AskEngineUpdateAll = { name: "ask:device-manager:engine:updateAll"; @@ -53,6 +64,10 @@ export class DeviceManagerEngine extends AbstractEngine { this.context = plugin.context; + onAsk("ask:device-manager:engine:list", async ({ group }) => + this.list(group) + ); + onAsk( "ask:device-manager:engine:updateAll", async () => { diff --git a/package-lock.json b/package-lock.json index 1618dc12..2ac2e5a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "kuzzle-sdk": "^7.10.7", "ts-jest": "^29.0.5", "ts-node": "^10.9.1", + "type-fest": "^3.7.2", "typescript": "^4.9.5" }, "peerDependencies": { @@ -1981,17 +1982,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.4.0.tgz", - "integrity": "sha512-PEPg6RHlB9cFwoTMNENNrQFL0cXX04voWr2UPwQBJ3pVs7Mt8Y1oLWdUeMdGEwZE8HFFlujq8gS9enmyiQ8pLg==", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4444,6 +4434,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -8910,11 +8911,11 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.7.2.tgz", + "integrity": "sha512-f9BHrLjRJ4MYkfOsnC/53PNDzZJcVo14MqLp2+hXE39p5bgwqohxR5hDZztwxlbxmIVuvC2EFAKrAkokq23PLA==", "engines": { - "node": ">=10" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index 37db4c2d..f1cd1e95 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "kuzzle-sdk": "^7.10.7", "ts-jest": "^29.0.5", "ts-node": "^10.9.1", + "type-fest": "^3.7.2", "typescript": "^4.9.5" }, "peerDependencies": { diff --git a/tests/scenario/modules/models/asset-model-metadata-propagation.test.ts b/tests/scenario/modules/models/asset-model-metadata-propagation.test.ts new file mode 100644 index 00000000..4d8d9203 --- /dev/null +++ b/tests/scenario/modules/models/asset-model-metadata-propagation.test.ts @@ -0,0 +1,361 @@ +import { ResponsePayload } from "kuzzle-sdk"; +import { PartialDeep } from "type-fest"; +import { + ApiAssetCreateRequest, + ApiAssetGetRequest, + ApiAssetGetResult, +} from "../../../../lib/modules/asset"; +import { + ApiModelDeleteAssetRequest, + ApiModelWriteAssetRequest, +} from "../../../../lib/modules/model"; +import { setupHooks } from "../../../helpers"; + +jest.setTimeout(10000); + +describe("Asset model metadata propagation", () => { + const sdk = setupHooks(); + + beforeAll(async () => { + if ( + await sdk.document.exists( + "device-manager", + "models", + "model-asset-Pallet" + ) + ) { + await sdk.query({ + controller: "device-manager/models", + action: "deleteAsset", + _id: "model-asset-Pallet", + }); + } + }); + + afterEach(async () => { + await sdk.query({ + controller: "device-manager/models", + action: "deleteAsset", + _id: "model-asset-Pallet", + }); + }); + + it("should add new metadata with default value set on linked assets in all engines", async () => { + await sdk.query({ + controller: "device-manager/models", + action: "writeAsset", + body: { + engineGroup: "commons", + model: "Pallet", + metadataMappings: { + depth: { type: "integer" }, + width: { type: "integer" }, + }, + defaultValues: { + depth: 80, + width: 120, + }, + }, + }); + + await sdk.query({ + controller: "device-manager/assets", + action: "create", + engineId: "engine-ayse", + body: { + model: "Pallet", + reference: "unlinked1", + metadata: { + depth: 82, + width: 122, + }, + }, + }); + + await sdk.query({ + controller: "device-manager/assets", + action: "create", + engineId: "engine-kuzzle", + body: { + model: "Pallet", + reference: "unlinked2", + metadata: { + depth: 83, + width: 123, + }, + }, + }); + + await sdk.query({ + controller: "device-manager/models", + action: "writeAsset", + body: { + engineGroup: "commons", + model: "Pallet", + metadataMappings: { + depth: { type: "integer" }, + width: { type: "integer" }, + tareWeight: { type: "integer" }, + }, + defaultValues: { + depth: 80, + width: 120, + tareWeight: 23, + }, + }, + }); + + await expect( + sdk.query({ + controller: "device-manager/assets", + action: "get", + engineId: "engine-ayse", + _id: "Pallet-unlinked1", + }) + ).resolves.toMatchObject>>({ + result: { + _source: { + metadata: { + depth: 82, + width: 122, + tareWeight: 23, + }, + }, + }, + }); + + await expect( + sdk.query({ + controller: "device-manager/assets", + action: "get", + engineId: "engine-kuzzle", + _id: "Pallet-unlinked2", + }) + ).resolves.toMatchObject>>({ + result: { + _source: { + metadata: { + depth: 83, + width: 123, + tareWeight: 23, + }, + }, + }, + }); + }); + + it("should add new metadata with null value if no default value set on linked assets in all engines", async () => { + await sdk.query({ + controller: "device-manager/models", + action: "writeAsset", + body: { + engineGroup: "commons", + model: "Pallet", + metadataMappings: { + depth: { type: "integer" }, + width: { type: "integer" }, + }, + defaultValues: { + depth: 80, + width: 120, + }, + }, + }); + + await sdk.query({ + controller: "device-manager/assets", + action: "create", + engineId: "engine-ayse", + body: { + model: "Pallet", + reference: "unlinked3", + metadata: { + depth: 82, + width: 122, + }, + }, + }); + + await sdk.query({ + controller: "device-manager/assets", + action: "create", + engineId: "engine-kuzzle", + body: { + model: "Pallet", + reference: "unlinked4", + metadata: { + depth: 83, + width: 123, + }, + }, + }); + + await sdk.query({ + controller: "device-manager/models", + action: "writeAsset", + body: { + engineGroup: "commons", + model: "Pallet", + metadataMappings: { + depth: { type: "integer" }, + width: { type: "integer" }, + tareWeight: { type: "integer" }, + }, + defaultValues: { + depth: 80, + width: 120, + }, + }, + }); + + await expect( + sdk.query({ + controller: "device-manager/assets", + action: "get", + engineId: "engine-ayse", + _id: "Pallet-unlinked3", + }) + ).resolves.toMatchObject>>({ + result: { + _source: { + metadata: { + depth: 82, + width: 122, + tareWeight: null, + }, + }, + }, + }); + + await expect( + sdk.query({ + controller: "device-manager/assets", + action: "get", + engineId: "engine-kuzzle", + _id: "Pallet-unlinked4", + }) + ).resolves.toMatchObject>>({ + result: { + _source: { + metadata: { + depth: 83, + width: 123, + tareWeight: null, + }, + }, + }, + }); + }); + + it("should delete removed metadata on linked assets in all engines", async () => { + await sdk.query({ + controller: "device-manager/models", + action: "writeAsset", + body: { + engineGroup: "commons", + model: "Pallet", + metadataMappings: { + depth: { type: "integer" }, + width: { type: "integer" }, + tareWeight: { type: "integer" }, + }, + defaultValues: { + depth: 80, + width: 120, + tareWeight: 23, + }, + }, + }); + + await sdk.query({ + controller: "device-manager/assets", + action: "create", + engineId: "engine-ayse", + body: { + model: "Pallet", + reference: "unlinked5", + metadata: { + depth: 82, + width: 122, + tareWeight: 25, + }, + }, + }); + + await sdk.query({ + controller: "device-manager/assets", + action: "create", + engineId: "engine-kuzzle", + body: { + model: "Pallet", + reference: "unlinked6", + metadata: { + depth: 83, + width: 123, + tareWeight: 23, + }, + }, + }); + + await sdk.query({ + controller: "device-manager/models", + action: "writeAsset", + body: { + engineGroup: "commons", + model: "Pallet", + metadataMappings: { + depth: { type: "integer" }, + width: { type: "integer" }, + }, + defaultValues: { + depth: 80, + width: 120, + }, + }, + }); + + const asset5 = await sdk.query({ + controller: "device-manager/assets", + action: "get", + engineId: "engine-ayse", + _id: "Pallet-unlinked5", + }); + + expect(asset5).toMatchObject< + PartialDeep> + >({ + result: { + _source: { + metadata: { + depth: 82, + width: 122, + }, + }, + }, + }); + + expect(asset5).not.toHaveProperty("result._source.metadata.tareWeight"); + + const asset6 = await sdk.query({ + controller: "device-manager/assets", + action: "get", + engineId: "engine-kuzzle", + _id: "Pallet-unlinked6", + }); + + expect(asset6).toMatchObject< + PartialDeep> + >({ + result: { + _source: { + metadata: { + depth: 83, + width: 123, + }, + }, + }, + }); + + expect(asset6).not.toHaveProperty("result._source.metadata.tareWeight"); + }); +});