From 5b57ba36f034765bd9f0fe14172f8b108cadd50d Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Thu, 14 Oct 2021 11:21:45 +0200 Subject: [PATCH] Provide a migration from the old storage location and format to the new one Signed-off-by: Dominik Henneke --- .changeset/cool-deers-cough.md | 6 +- .../DefaultStarredEntitiesApi.test.ts | 11 ++ .../DefaultStarredEntitiesApi.ts | 4 + .../apis/StarredEntitiesApi/migration.test.ts | 113 ++++++++++++++++++ .../src/apis/StarredEntitiesApi/migration.ts | 62 ++++++++++ 5 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 plugins/catalog-react/src/apis/StarredEntitiesApi/migration.test.ts create mode 100644 plugins/catalog-react/src/apis/StarredEntitiesApi/migration.ts diff --git a/.changeset/cool-deers-cough.md b/.changeset/cool-deers-cough.md index 0c07c91b96..4f4921db8d 100644 --- a/.changeset/cool-deers-cough.md +++ b/.changeset/cool-deers-cough.md @@ -6,7 +6,5 @@ Introduce a new `StarredEntitiesApi` that is used in the `useStarredEntities` hook. The `@backstage/plugin-catalog` installs a default implementation that is backed by the `StorageApi`, but one can also override the `starredEntitiesApiRef`. -**BREAKING** All previously stored entities get lost with this update, because: - -1. The storage format has been migrated from a custom string to an entity reference. -2. The storage key in the local storage has been changed. +This change also updates the storage format from a custom string to an entity reference and moves the location in the local storage. +A migration will convert the previously starred entities to the location on the first load of Backstage. diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.test.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.test.ts index be67b03dbb..55b992fadf 100644 --- a/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.test.ts +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.test.ts @@ -18,6 +18,9 @@ import { stringifyEntityRef } from '@backstage/catalog-model'; import { StorageApi } from '@backstage/core-plugin-api'; import { MockStorageApi } from '@backstage/test-utils'; import { DefaultStarredEntitiesApi } from './DefaultStarredEntitiesApi'; +import { performMigrationToTheNewBucket } from './migration'; + +jest.mock('./migration'); describe('DefaultStarredEntitiesApi', () => { let mockStorage: StorageApi; @@ -32,6 +35,8 @@ describe('DefaultStarredEntitiesApi', () => { }); beforeEach(() => { + (performMigrationToTheNewBucket as jest.Mock).mockResolvedValue(undefined); + mockStorage = MockStorageApi.create(); starredEntitiesApi = new DefaultStarredEntitiesApi({ storageApi: mockStorage, @@ -42,6 +47,12 @@ describe('DefaultStarredEntitiesApi', () => { jest.resetAllMocks(); }); + describe('constructor', () => { + it('should call migration', () => { + expect(performMigrationToTheNewBucket).toBeCalledTimes(1); + }); + }); + describe('toggleStarred', () => { it('should star unstarred entity', async () => { expect(starredEntitiesApi.isStarred(mockEntityRef)).toBe(false); diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.ts index 92a0eaebf2..87d99c8904 100644 --- a/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.ts +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/DefaultStarredEntitiesApi.ts @@ -16,6 +16,7 @@ import { Observable, StorageApi } from '@backstage/core-plugin-api'; import ObservableImpl from 'zen-observable'; +import { performMigrationToTheNewBucket } from './migration'; import { StarredEntitiesApi } from './StarredEntitiesApi'; /** @@ -28,6 +29,9 @@ export class DefaultStarredEntitiesApi implements StarredEntitiesApi { private starredEntities: Set; constructor(opts: { storageApi: StorageApi }) { + // no need to await. The updated content will be caught by the observe$ + performMigrationToTheNewBucket(opts).then(); + this.settingsStore = opts.storageApi.forBucket('starredEntities'); this.starredEntities = new Set( diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.test.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.test.ts new file mode 100644 index 0000000000..1ff3be811f --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StorageApi } from '@backstage/core-plugin-api'; +import { MockStorageApi } from '@backstage/test-utils'; +import { performMigrationToTheNewBucket } from './migration'; + +describe('performMigrationToTheNewBucket', () => { + let mockStorage: StorageApi; + + beforeEach(() => { + mockStorage = MockStorageApi.create(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should migrate', async () => { + const oldBucket = mockStorage.forBucket('settings'); + const newBucket = mockStorage.forBucket('starredEntities'); + + // fill NEW bucket + await newBucket.set('entityRefs', ['component:default/c']); + + // fill OLD bucket + await oldBucket.set('starredEntities', [ + 'entity:Component:default:a', + 'entity:template:custom:b', + ]); + expect(oldBucket.get('starredEntities')).not.toBeUndefined(); + + await performMigrationToTheNewBucket({ storageApi: mockStorage }); + + // read NEW bucket + expect(await newBucket.get('entityRefs')).toEqual([ + 'component:default/c', + 'component:default/a', + 'template:custom/b', + ]); + + // OLD bucket should be removed + expect(oldBucket.get('starredEntities')).toBeUndefined(); + }); + + it('should ignore invalid entries', async () => { + const oldBucket = mockStorage.forBucket('settings'); + const newBucket = mockStorage.forBucket('starredEntities'); + + // fill OLD bucket + await oldBucket.set('starredEntities', [ + 'entity:Component:default:a', + 1, + 'entity:Component:a', + 'invalid', + ]); + expect(oldBucket.get('starredEntities')).not.toBeUndefined(); + + await performMigrationToTheNewBucket({ storageApi: mockStorage }); + + // read NEW bucket + expect(await newBucket.get('entityRefs')).toEqual(['component:default/a']); + + // OLD bucket should be removed + expect(oldBucket.get('starredEntities')).toBeUndefined(); + }); + + it('should skip migration without old starred entities', async () => { + const newBucket = mockStorage.forBucket('starredEntities'); + + // fill NEW bucket + const expectedEntries = ['component:default/a']; + await newBucket.set('entityRefs', expectedEntries); + + await performMigrationToTheNewBucket({ storageApi: mockStorage }); + + // read NEW bucket + expect(newBucket.get('entityRefs')).toBe(expectedEntries); + }); + + it('should skip migration with non-array old starred entities', async () => { + const oldBucket = mockStorage.forBucket('settings'); + const newBucket = mockStorage.forBucket('starredEntities'); + + // fill OLD bucket with invalid content + await oldBucket.set('starredEntities', 'invalid'); + + // fill NEW bucket + const expectedEntries = ['component:default/a']; + await newBucket.set('entityRefs', expectedEntries); + + await performMigrationToTheNewBucket({ storageApi: mockStorage }); + + // read NEW bucket + expect(newBucket.get('entityRefs')).toBe(expectedEntries); + + // OLD bucket should be unchanged + expect(oldBucket.get('starredEntities')).toBe('invalid'); + }); +}); diff --git a/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.ts b/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.ts new file mode 100644 index 0000000000..7147d449ab --- /dev/null +++ b/plugins/catalog-react/src/apis/StarredEntitiesApi/migration.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { StorageApi } from '@backstage/core-plugin-api'; +import { isArray, isString } from 'lodash'; + +/** + * Migrate the starred entities from the old format (entity:::) from the + * old storage location (/settings/starredEntities) to entity references in the new location + * (/starredEntities/entityRefs). + * + * This will only be executed once since the old location is cleared. + * + * @param storageApi - the StorageApi to migrate + */ +export async function performMigrationToTheNewBucket({ + storageApi, +}: { + storageApi: StorageApi; +}) { + const source = storageApi.forBucket('settings'); + const target = storageApi.forBucket('starredEntities'); + + const oldStarredEntities = source.get('starredEntities'); + + if (!isArray(oldStarredEntities)) { + // nothing to do + return; + } + + const targetEntities = new Set(target.get('entityRefs') ?? []); + + oldStarredEntities + .filter(isString) + // extract the old format 'entity:::' + .map(old => old.split(':')) + // check if the format is valid + .filter(split => split.length === 4 && split[0] === 'entity') + // convert to entity references + .map(([_, kind, namespace, name]) => + stringifyEntityRef({ kind, namespace, name }), + ) + .forEach(e => targetEntities.add(e)); + + await target.set('entityRefs', Array.from(targetEntities)); + + await source.remove('starredEntities'); +}