Provide a migration from the old storage location and format to the new one

Signed-off-by: Dominik Henneke <dominik.henneke@sda-se.com>
This commit is contained in:
Dominik Henneke
2021-10-14 11:21:45 +02:00
parent 4ec21812bb
commit 5b57ba36f0
5 changed files with 192 additions and 4 deletions
+2 -4
View File
@@ -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.
@@ -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);
@@ -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<string>;
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(
@@ -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');
});
});
@@ -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:<kind>:<namespace>:<name>) 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:<kind>:<namespace>:<name>'
.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');
}