diff --git a/meteor/server/api/rest/v1/__tests__/typeConversions.spec.ts b/meteor/server/api/rest/v1/__tests__/typeConversions.spec.ts new file mode 100644 index 0000000000..f52c853d37 --- /dev/null +++ b/meteor/server/api/rest/v1/__tests__/typeConversions.spec.ts @@ -0,0 +1,87 @@ +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { buildStudioFromResolved } from '../typeConversion' +import { wrapDefaultObject } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { DBStudio, IStudioSettings } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { OrganizationId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { IBlueprintConfig, StudioBlueprintManifest } from '@sofie-automation/blueprints-integration' +import { APIStudio } from '../../../../lib/rest/v1' + +describe('buildStudioFromResolved', () => { + test('preserves existing fields and overrides API ones', async () => { + const blueprintManifest = {} as unknown as StudioBlueprintManifest + const apiStudio = { + name: 'New Name', + settings: { frameRate: 25 } as IStudioSettings, + config: { someValue: 1 }, + supportedShowStyleBase: ['A'], + } as APIStudio + const existingStudio = { + _id: protectString('studio0'), + organizationId: protectString('orgId'), + name: 'Studio 0', + settingsWithOverrides: wrapDefaultObject({ frameRate: 50, allowHold: true } as IStudioSettings), + blueprintConfigWithOverrides: wrapDefaultObject({ B: 0 } as IBlueprintConfig), + } as DBStudio + const studio = await buildStudioFromResolved({ + apiStudio, + existingStudio, + blueprintManifest, + blueprintId: protectString('bp1'), + studioId: protectString('studio0'), + }) + + expect(studio._id).toBe('studio0') + expect(studio.name).toBe('New Name') + expect(studio.organizationId).toBe('orgId') + expect(studio.blueprintId).toBe('bp1') + expect(studio.settingsWithOverrides.overrides).toContainEqual({ + op: 'set', + path: 'frameRate', + value: 25, + }) + expect(studio.blueprintConfigWithOverrides.overrides).toContainEqual({ + op: 'set', + path: 'someValue', + value: 1, + }) + }) + test('preserves existing fields and overrides API ones with blueprintConfigFromAPI defined', async () => { + const blueprintManifest = { blueprintConfigFromAPI: async () => ({ fromBlueprints: true }) } as any + const apiStudio = { + name: 'New Name', + settings: { frameRate: 25 } as IStudioSettings, + config: { someValue: 1 }, + supportedShowStyleBase: ['A'], + blueprintConfigPresetId: 'preset0', + } as APIStudio + const existingStudio = { + _id: protectString('studio0'), + organizationId: protectString('orgId'), + name: 'Studio 0', + settingsWithOverrides: wrapDefaultObject({ frameRate: 50 } as IStudioSettings), + blueprintConfigWithOverrides: wrapDefaultObject({ B: 0 } as IBlueprintConfig), + } as DBStudio + const studio = await buildStudioFromResolved({ + apiStudio, + existingStudio, + blueprintManifest, + blueprintId: protectString('bp1'), + studioId: protectString('studio0'), + }) + + expect(studio._id).toBe('studio0') + expect(studio.name).toBe('New Name') + expect(studio.organizationId).toBe('orgId') + expect(studio.blueprintId).toBe('bp1') + expect(studio.settingsWithOverrides.overrides).toContainEqual({ + op: 'set', + path: 'frameRate', + value: 25, + }) + expect(studio.blueprintConfigWithOverrides.overrides).toContainEqual({ + op: 'set', + path: 'fromBlueprints', + value: true, + }) + }) +}) diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index 1d9585df44..c4958f5437 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -304,19 +304,42 @@ export async function studioFrom(apiStudio: APIStudio, existingId?: StudioId): P } if (!blueprint) return undefined - let studio: DBStudio | undefined - if (existingId) studio = await Studios.findOneAsync(existingId) + let existingStudio: DBStudio | undefined + if (existingId) existingStudio = await Studios.findOneAsync(existingId) const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest + + return buildStudioFromResolved({ + apiStudio, + existingStudio, + blueprintManifest, + blueprintId: blueprint._id, + studioId: existingId ?? getRandomId(), + }) +} + +export async function buildStudioFromResolved({ + apiStudio, + existingStudio, + blueprintManifest, + blueprintId, + studioId, +}: { + apiStudio: APIStudio + existingStudio?: DBStudio + blueprintManifest: StudioBlueprintManifest + blueprintId: BlueprintId + studioId: StudioId +}): Promise { let blueprintConfig: ObjectWithOverrides if (typeof blueprintManifest.blueprintConfigFromAPI !== 'function') { - blueprintConfig = studio - ? updateOverrides(studio.blueprintConfigWithOverrides, apiStudio.config as IBlueprintConfig) + blueprintConfig = existingStudio + ? updateOverrides(existingStudio.blueprintConfigWithOverrides, apiStudio.config as IBlueprintConfig) : wrapDefaultObject({}) } else { - blueprintConfig = studio + blueprintConfig = existingStudio ? updateOverrides( - studio.blueprintConfigWithOverrides, + existingStudio.blueprintConfigWithOverrides, await StudioBlueprintConfigFromAPI(apiStudio, blueprintManifest) ) : convertObjectIntoOverrides(await StudioBlueprintConfigFromAPI(apiStudio, blueprintManifest)) @@ -325,15 +348,7 @@ export async function studioFrom(apiStudio: APIStudio, existingId?: StudioId): P const studioSettings = studioSettingsFrom(apiStudio.settings) return { - _id: existingId ?? getRandomId(), - name: apiStudio.name, - blueprintId: blueprint?._id, - blueprintConfigPresetId: apiStudio.blueprintConfigPresetId, - blueprintConfigWithOverrides: blueprintConfig, - settingsWithOverrides: studio - ? updateOverrides(studio.settingsWithOverrides, studioSettings) - : wrapDefaultObject(studioSettings), - supportedShowStyleBase: apiStudio.supportedShowStyleBase?.map((id) => protectString(id)) ?? [], + // fill in the blanks if there is no existing studio organizationId: null, mappingsWithOverrides: wrapDefaultObject({}), routeSetsWithOverrides: wrapDefaultObject({}), @@ -350,6 +365,20 @@ export async function studioFrom(apiStudio: APIStudio, existingId?: StudioId): P }, lastBlueprintConfig: undefined, lastBlueprintFixUpHash: undefined, + + // take what existing studio might have + ...existingStudio, + + // override what apiStudio can + _id: studioId, + name: apiStudio.name, + blueprintId, + blueprintConfigPresetId: apiStudio.blueprintConfigPresetId, + blueprintConfigWithOverrides: blueprintConfig, + settingsWithOverrides: existingStudio + ? updateOverrides(existingStudio.settingsWithOverrides, studioSettings) + : wrapDefaultObject(studioSettings), + supportedShowStyleBase: apiStudio.supportedShowStyleBase?.map((id) => protectString(id)) ?? [], } } diff --git a/packages/corelib/src/settings/__tests__/objectWithOverrides.spec.ts b/packages/corelib/src/settings/__tests__/objectWithOverrides.spec.ts index aecf458d49..2de48c7573 100644 --- a/packages/corelib/src/settings/__tests__/objectWithOverrides.spec.ts +++ b/packages/corelib/src/settings/__tests__/objectWithOverrides.spec.ts @@ -1,3 +1,4 @@ +import clone = require('fast-clone') import { literal } from '../../lib' import { applyAndValidateOverrides, @@ -186,8 +187,8 @@ describe('applyAndValidateOverrides', () => { }, }, overrides: [ - { op: 'set', path: 'valA', value: 'def' }, { op: 'set', path: 'valB.valD', value: 'uvw' }, + { op: 'set', path: 'valA', value: 'def' }, { op: 'set', path: 'valB.valC', value: 6 }, ], }) @@ -235,4 +236,189 @@ describe('applyAndValidateOverrides', () => { }) ) }) + + test('update overrides - add to existing overrides', () => { + const inputObj: BasicType = { + valA: 'abc', + valB: { + valC: 5, + valD: 'foo', + }, + } + + const inputObjWithOverrides: ObjectWithOverrides = { + defaults: inputObj, + overrides: [ + { op: 'set', path: 'valA', value: 'def' }, + { op: 'set', path: 'valB.valC', value: 6 }, + ], + } + + const updateObj: BasicType = { + valA: 'ghi', + valB: { + valC: 7, + valD: 'bar', + }, + } + + const res = updateOverrides(inputObjWithOverrides, updateObj) + expect(res).toBeTruthy() + + expect(res).toStrictEqual( + literal>({ + defaults: { + valA: 'abc', + valB: { + valC: 5, + valD: 'foo', + }, + }, + overrides: [ + { op: 'set', path: 'valA', value: 'ghi' }, + { op: 'set', path: 'valB.valC', value: 7 }, + { op: 'set', path: 'valB.valD', value: 'bar' }, + ], + }) + ) + }) + + test('update overrides - add to existing overrides #2', () => { + const inputObj = { + valA: 'abc', + valB: { + '0': { propA: 35, propB: 'Mic 1' }, + '1': { propA: 36, propB: 'Mic 2' }, + '2': { propA: 37, propB: 'Mic 3' }, + }, + } + + const inputObjWithOverrides: ObjectWithOverrides = { + defaults: inputObj, + overrides: [ + { + op: 'set', + path: 'valB.0.propC', + value: true, + }, + { + op: 'set', + path: 'valB.0.propD', + value: true, + }, + { op: 'set', path: 'valB.1.propC', value: true }, + ], + } + + const updateObj = { + valA: 'abc', + valB: { + '0': { propA: 35, propB: 'Mic 1', propC: true, propD: true }, + '1': { propA: 36, propB: 'Mic 2', propC: true }, + '2': { propA: 37, propB: 'Mic 3', propC: true }, + }, + } + + const res = updateOverrides(inputObjWithOverrides, updateObj) + expect(res).toBeTruthy() + + expect(res).toStrictEqual( + literal>({ + defaults: clone(inputObj), + overrides: [ + { + op: 'set', + path: 'valB.0.propC', + value: true, + }, + { + op: 'set', + path: 'valB.0.propD', + value: true, + }, + { op: 'set', path: 'valB.1.propC', value: true }, + { op: 'set', path: 'valB.2.propC', value: true }, + ], + }) + ) + }) + + test('update overrides - delete key', () => { + const inputObj = { + valA: 'abc', + valB: { + '0': { propA: 35, propB: 'Mic 1' }, + '1': { propA: 36, propB: 'Mic 2' }, + '2': { propA: 37, propB: 'Mic 3' }, + }, + } + + const inputObjWithOverrides: ObjectWithOverrides = { + defaults: inputObj, + overrides: [], + } + + const updateObj = { + valA: 'abc', + valB: { + '0': { propA: 35, propB: 'Mic 1' }, + '1': { propA: 36, propB: 'Mic 2' }, + }, + } + + const res = updateOverrides(inputObjWithOverrides, updateObj) + expect(res).toBeTruthy() + + expect(res).toStrictEqual( + literal>({ + defaults: clone(inputObj), + overrides: [ + { + op: 'delete', + path: 'valB.2', + }, + ], + }) + ) + }) + + test('update overrides - delete value', () => { + const inputObj = { + valA: 'abc', + valB: { + '0': { propA: 35, propB: 'Mic 1' }, + '1': { propA: 36, propB: 'Mic 2' }, + '2': { propA: 37, propB: 'Mic 3' }, + }, + } + + const inputObjWithOverrides: ObjectWithOverrides = { + defaults: inputObj, + overrides: [], + } + + const updateObj = { + valA: 'abc', + valB: { + '0': { propA: 35, propB: 'Mic 1' }, + '1': { propA: 36, propB: 'Mic 2' }, + '2': { propA: 37 }, + }, + } + + const res = updateOverrides(inputObjWithOverrides, updateObj) + expect(res).toBeTruthy() + + expect(res).toStrictEqual( + literal>({ + defaults: clone(inputObj), + overrides: [ + { + op: 'delete', + path: 'valB.2.propB', + }, + ], + }) + ) + }) }) diff --git a/packages/corelib/src/settings/objectWithOverrides.ts b/packages/corelib/src/settings/objectWithOverrides.ts index 03783ab565..8bc4d28c6f 100644 --- a/packages/corelib/src/settings/objectWithOverrides.ts +++ b/packages/corelib/src/settings/objectWithOverrides.ts @@ -2,6 +2,7 @@ import objectPath = require('object-path') import { ReadonlyDeep } from 'type-fest' import _ = require('underscore') import { assertNever, clone, literal } from '../lib' +import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' /** * This is an object which allows for overrides to be tracked and reapplied @@ -88,55 +89,96 @@ export function updateOverrides( curObj: ReadonlyDeep>, rawObj: ReadonlyDeep ): ObjectWithOverrides { - const result: ObjectWithOverrides = { defaults: clone(curObj.defaults), overrides: [] } - for (const [key, value] of Object.entries(rawObj)) { - const override = curObj.overrides.find((ov) => { - const parentPath = getParentObjectPath(ov.path) - return key === (parentPath ? parentPath : ov.path) - }) - if (override) { - // Some or all members of the property are already overridden in curObj - if (objectPath.has(rawObj, override.path)) { - const rawValue = objectPath.get(rawObj, override.path) - if (override.op === 'delete' || (override.op === 'set' && _.isEqual(rawValue, override.value))) { - // Preserve all existing delete overrides and any set overrides where the value is not updated - result.overrides.push(override) - } + const overrides = getOverridesToPreserve(curObj, rawObj) + + // apply preserved overrides on top of the defaults + const tmpObj: ReadonlyDeep> = { defaults: clone(curObj.defaults), overrides: overrides } + const flattenedObjWithPreservedOverrides = applyAndValidateOverrides(tmpObj).obj + + // calculate overrides that are still missing + recursivelyGenerateOverrides(flattenedObjWithPreservedOverrides, rawObj, [], overrides) + + return { defaults: clone(curObj.defaults), overrides: overrides } +} + +function getOverridesToPreserve( + curObj: ReadonlyObjectDeep>, + rawObj: ReadonlyDeep +) { + const overrides: SomeObjectOverrideOp[] = [] + curObj.overrides.forEach((override) => { + const rawValue = objectPath.get(rawObj, override.path) + if ( + (override.op === 'delete' && rawValue === undefined) || + (override.op === 'set' && _.isEqual(rawValue, override.value)) + ) { + // what was deleted, remains deleted, or what was set remaines equal + overrides.push(override) + return + } + const defaultValue = objectPath.get(curObj.defaults, override.path) + if (override.op === 'delete') { + if (_.isEqual(rawValue, defaultValue)) { + // previously deleted, brought back to defaults + return } + // was deleted, but is brought to non-default value + overrides.push({ + op: 'set', + path: override.path, + value: rawValue, + }) } + }) + return overrides +} - // check the values of the raw object against the current object, generating an override for each difference - const appliedCurObj = applyAndValidateOverrides(curObj).obj - for (const [curKey, curValue] of Object.entries(appliedCurObj)) { - if (key === curKey && !_.isEqual(value, curValue)) { - // Some or all members of the property have been modified - if (typeof value === 'object') { - // check one level down info the potentially modified object - for (const [rawKey, rawValue] of Object.entries(value)) { - if (!_.isEqual(rawValue, curValue[rawKey])) { - result.overrides.push( - literal({ - op: 'set', - path: `${key}.${rawKey}`, - value: rawValue, - }) - ) - } - } - } else { - result.overrides.push( - literal({ - op: 'set', - path: key, - value: value, - }) - ) - } - } +function recursivelyGenerateOverrides( + curObj: ReadonlyDeep, + rawObj: ReadonlyDeep, + path: string[], + outOverrides: SomeObjectOverrideOp[] +) { + for (const [curKey, curValue] of Object.entries(curObj)) { + const rawValue = objectPath.get(rawObj, curKey) + const fullKeyPath = [...path, curKey] + const fullKeyPathString = fullKeyPath.join('.') + if (curValue !== undefined && rawValue === undefined) { + outOverrides.push({ + op: 'delete', + path: fullKeyPathString, + }) + continue + } + if (Array.isArray(rawValue) && !_.isEqual(curValue, rawValue)) { + outOverrides.push({ + op: 'set', + path: fullKeyPathString, + value: rawValue, + }) + } + if (typeof curValue === 'object' && typeof rawValue === 'object') { + recursivelyGenerateOverrides(curValue, rawValue, fullKeyPath, outOverrides) + continue + } + if (curValue !== rawValue) { + outOverrides.push({ + op: 'set', + path: fullKeyPathString, + value: rawValue, + }) + } + } + for (const [rawKey, rawValue] of Object.entries(rawObj)) { + const curValue = objectPath.get(curObj, rawKey) + if (curValue === undefined && rawValue !== undefined) { + outOverrides.push({ + op: 'set', + path: [...path, rawKey].join('.'), + value: rawValue, + }) } - // } } - return result } /**