Merge pull request #5551 from backstage/iameap/cache

Add a storage-agnostic cache manager to backend-common
This commit is contained in:
Fredrik Adelöw
2021-05-11 16:57:35 +02:00
committed by GitHub
16 changed files with 733 additions and 7 deletions
+52
View File
@@ -0,0 +1,52 @@
---
'@backstage/backend-common': minor
---
Introducing: a standard API for App Integrators to configure cache stores and Plugin Developers to interact with them.
Two cache stores are currently supported.
- `memory`, which is a very simple in-memory key/value store, intended for local development.
- `memcache`, which can be used to connect to a memcache host.
Configuring and working with cache stores is very similar to the process for database connections.
```yaml
backend:
cache:
store: memcache
connection: user:pass@cache.example.com:11211
```
```typescript
import { CacheManager } from '@backstage/backend-common';
// Instantiating a cache client for a plugin.
const cacheManager = CacheManager.fromConfig(config);
const somePluginCache = cacheManager.forPlugin('somePlugin');
const cacheClient = somePluginCache.getClient();
// Using the cache client:
const cachedValue = await cacheClient.get('someKey');
if (cachedValue) {
return cachedValue;
} else {
const someValue = await someExpensiveProcess();
await cacheClient.set('someKey', someValue);
}
await cacheClient.delete('someKey');
```
Cache clients deal with TTLs in milliseconds. A TTL can be provided as a defaultTtl when getting a client, or may be passed when setting specific objects. If no TTL is provided, data will be persisted indefinitely.
```typescript
// Getting a client with a default TTL
const cacheClient = somePluginCache.getClient({
defaultTtl: 3600000,
});
// Setting a TTL on a per-object basis.
cacheClient.set('someKey', data, { ttl: 3600000 });
```
Configuring a cache store is optional. Even when no cache store is configured, the cache manager will dutifully pass plugins a manager that resolves a cache client that does not actually write or read any data.
+1
View File
@@ -167,6 +167,7 @@ mailto
maintainership
makefile
md
memcache
microsite
middleware
minikube
+19
View File
@@ -16,6 +16,7 @@ import { GithubCredentialsProvider } from '@backstage/integration';
import { GitHubIntegration } from '@backstage/integration';
import { GitLabIntegration } from '@backstage/integration';
import * as http from 'http';
import { JsonValue } from '@backstage/config';
import { Knex } from 'knex';
import { Logger } from 'winston';
import { MergeResult } from 'isomorphic-git';
@@ -62,6 +63,19 @@ export class BitbucketUrlReader implements UrlReader {
toString(): string;
}
// @public
export interface CacheClient {
delete(key: string): Promise<void>;
get(key: string): Promise<JsonValue | undefined>;
set(key: string, value: JsonValue, options?: CacheSetOptions): Promise<void>;
}
// @public
export class CacheManager {
forPlugin(pluginId: string): PluginCacheManager;
static fromConfig(config: Config): CacheManager;
}
// @public (undocumented)
export const coloredFormat: winston.Logform.Format;
@@ -238,6 +252,11 @@ export function loadBackendConfig(options: Options): Promise<Config>;
// @public
export function notFoundHandler(): RequestHandler;
// @public
export type PluginCacheManager = {
getClient: (options?: ClientOptions) => CacheClient;
};
// @public
export interface PluginDatabaseManager {
getClient(): Promise<Knex>;
+14
View File
@@ -68,6 +68,20 @@ export interface Config {
connection: string | object;
};
/** Cache connection configuration, select cache type using the `store` field */
cache?:
| {
store: 'memory';
}
| {
store: 'memcache';
/**
* A memcache connection string in the form `user:pass@host:port`.
* @secret
*/
connection: string;
};
cors?: {
origin?: string | string[];
methods?: string | string[];
+2
View File
@@ -51,6 +51,8 @@
"git-url-parse": "^11.4.4",
"helmet": "^4.0.0",
"isomorphic-git": "^1.8.0",
"keyv": "^4.0.3",
"keyv-memcache": "^1.2.5",
"knex": "^0.95.1",
"lodash": "^4.17.15",
"logform": "^2.1.1",
+126
View File
@@ -0,0 +1,126 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { DefaultCacheClient } from './CacheClient';
import Keyv from 'keyv';
describe('CacheClient', () => {
let client: Keyv;
const b64 = (k: string) => Buffer.from(k).toString('base64');
beforeEach(() => {
client = new Keyv();
client.get = jest.fn();
client.set = jest.fn();
client.delete = jest.fn();
});
afterEach(() => jest.resetAllMocks());
describe('CacheClient.get', () => {
it('calls client with normalized key', async () => {
const sut = new DefaultCacheClient({ client });
const key = 'somekey';
await sut.get(key);
expect(client.get).toHaveBeenCalledWith(b64(key));
});
it('calls client with normalized key (very long key)', async () => {
const sut = new DefaultCacheClient({ client });
const key = 'x'.repeat(251);
await sut.get(key);
const spy = client.get as jest.Mock;
const actualKey = spy.mock.calls[0][0];
expect(actualKey).not.toEqual(b64(key));
expect(actualKey.length).toBeLessThan(250);
});
it('rejects on underlying error', async () => {
const sut = new DefaultCacheClient({ client });
const expectedError = new Error('Some runtime error');
client.get = jest.fn().mockRejectedValue(expectedError);
return expect(sut.get('someKey')).rejects.toEqual(expectedError);
});
it('resolves what underlying client resolves', async () => {
const sut = new DefaultCacheClient({ client });
const expectedValue = 'some value';
client.get = jest.fn().mockResolvedValue(expectedValue);
const actualValue = await sut.get('someKey');
return expect(actualValue).toEqual(expectedValue);
});
});
describe('CacheClient.set', () => {
it('calls client with normalized key', async () => {
const sut = new DefaultCacheClient({ client });
const key = 'somekey';
await sut.set(key, {});
const spy = client.set as jest.Mock;
const actualKey = spy.mock.calls[0][0];
expect(actualKey).toEqual(b64(key));
});
it('passes ttl to client when given', async () => {
const sut = new DefaultCacheClient({ client });
const expectedTtl = 3600;
await sut.set('someKey', {}, { ttl: expectedTtl });
const spy = client.set as jest.Mock;
const actualTtl = spy.mock.calls[0][2];
expect(actualTtl).toEqual(expectedTtl);
});
it('rejects on underlying error if configured', async () => {
const sut = new DefaultCacheClient({ client });
const expectedError = new Error('Some runtime error');
client.set = jest.fn().mockRejectedValue(expectedError);
return expect(sut.set('someKey', {})).rejects.toEqual(expectedError);
});
});
describe('CacheClient.delete', () => {
it('calls client with normalized key', async () => {
const sut = new DefaultCacheClient({ client });
const key = 'somekey';
await sut.delete(key);
const spy = client.delete as jest.Mock;
const actualKey = spy.mock.calls[0][0];
expect(actualKey).toEqual(b64(key));
});
it('rejects on underlying error if configured', async () => {
const sut = new DefaultCacheClient({ client });
const expectedError = new Error('Some runtime error');
client.delete = jest.fn().mockRejectedValue(expectedError);
return expect(sut.delete('someKey')).rejects.toEqual(expectedError);
});
});
});
+101
View File
@@ -0,0 +1,101 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { JsonValue } from '@backstage/config';
import { createHash } from 'crypto';
import Keyv from 'keyv';
type CacheClientArgs = {
client: Keyv;
};
type CacheSetOptions = {
/**
* Optional TTL in milliseconds. Defaults to the TTL provided when the client
* was set up (or no TTL if none are provided).
*/
ttl?: number;
};
/**
* A pre-configured, storage agnostic cache client suitable for use by
* Backstage plugins.
*/
export interface CacheClient {
/**
* Reads data from a cache store for the given key. If no data was found,
* returns undefined.
*/
get(key: string): Promise<JsonValue | undefined>;
/**
* Writes the given data to a cache store, associated with the given key. An
* optional TTL may also be provided, otherwise it defaults to the TTL that
* was provided when the client was instantiated.
*/
set(key: string, value: JsonValue, options?: CacheSetOptions): Promise<void>;
/**
* Removes the given key from the cache store.
*/
delete(key: string): Promise<void>;
}
/**
* A basic, concrete implementation of the CacheClient, suitable for almost
* all uses in Backstage.
*/
export class DefaultCacheClient implements CacheClient {
private readonly client: Keyv;
constructor({ client }: CacheClientArgs) {
this.client = client;
}
async get(key: string): Promise<JsonValue | undefined> {
const k = this.getNormalizedKey(key);
return await this.client.get(k);
}
async set(
key: string,
value: JsonValue,
opts: CacheSetOptions = {},
): Promise<void> {
const k = this.getNormalizedKey(key);
await this.client.set(k, value, opts.ttl);
}
async delete(key: string): Promise<void> {
const k = this.getNormalizedKey(key);
await this.client.delete(k);
}
/**
* Ensures keys are well-formed for any/all cache stores.
*/
private getNormalizedKey(candidateKey: string): string {
// Remove potentially invalid characters.
const wellFormedKey = Buffer.from(candidateKey).toString('base64');
// Memcache in particular doesn't do well with keys > 250 bytes.
if (wellFormedKey.length < 250) {
return wellFormedKey;
}
return createHash('md5').update(candidateKey).digest('base64');
}
}
+166
View File
@@ -0,0 +1,166 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { ConfigReader } from '@backstage/config';
import Keyv from 'keyv';
/* @ts-expect-error */
import KeyvMemcache from 'keyv-memcache';
import { DefaultCacheClient } from './CacheClient';
import { CacheManager } from './CacheManager';
import { NoStore } from './NoStore';
jest.createMockFromModule('keyv');
jest.mock('keyv');
jest.createMockFromModule('keyv-memcache');
jest.mock('keyv-memcache');
jest.mock('./CacheClient', () => {
return {
DefaultCacheClient: jest.fn(),
};
});
describe('CacheManager', () => {
const defaultConfigOptions = {
backend: {
cache: {
store: 'memory',
},
},
};
const defaultConfig = () => new ConfigReader(defaultConfigOptions);
afterEach(() => jest.resetAllMocks());
describe('CacheManager.fromConfig', () => {
it('accesses the backend.cache key', () => {
const getOptionalString = jest.fn();
const config = defaultConfig();
config.getOptionalString = getOptionalString;
CacheManager.fromConfig(config);
expect(getOptionalString.mock.calls[0][0]).toEqual('backend.cache.store');
expect(getOptionalString.mock.calls[1][0]).toEqual(
'backend.cache.connection',
);
});
it('does not require the backend.cache key', () => {
const config = new ConfigReader({ backend: {} });
expect(() => {
CacheManager.fromConfig(config);
}).not.toThrowError();
});
it('throws on unknown cache store', () => {
const config = new ConfigReader({
backend: { cache: { store: 'notreal' } },
});
expect(() => {
CacheManager.fromConfig(config);
}).toThrowError();
});
});
describe('CacheManager.forPlugin', () => {
const manager = CacheManager.fromConfig(defaultConfig());
it('connects to a cache store scoped to the plugin', async () => {
const pluginId = 'test1';
manager.forPlugin(pluginId).getClient();
const client = DefaultCacheClient as jest.Mock;
expect(client).toHaveBeenCalledTimes(1);
});
it('provides different plugins different cache clients', async () => {
const plugin1Id = 'test1';
const plugin2Id = 'test2';
const expectedTtl = 3600;
manager.forPlugin(plugin1Id).getClient({ defaultTtl: expectedTtl });
manager.forPlugin(plugin2Id).getClient({ defaultTtl: expectedTtl });
const client = DefaultCacheClient as jest.Mock;
const cache = (Keyv as unknown) as jest.Mock;
expect(cache).toHaveBeenCalledTimes(2);
expect(client).toHaveBeenCalledTimes(2);
const plugin1CallArgs = cache.mock.calls[0];
const plugin2CallArgs = cache.mock.calls[1];
expect(plugin1CallArgs[0].namespace).not.toEqual(
plugin2CallArgs[0].namespace,
);
});
});
describe('CacheManager.forPlugin stores', () => {
it('returns none client when no cache is configured', () => {
const manager = CacheManager.fromConfig(
new ConfigReader({ backend: {} }),
);
const expectedNamespace = 'test-plugin';
manager.forPlugin(expectedNamespace).getClient();
const cache = (Keyv as unknown) as jest.Mock;
const mockCalls = cache.mock.calls.splice(-1);
const callArgs = mockCalls[0];
expect(callArgs[0].store).toBeInstanceOf(NoStore);
});
it('returns memory client when explicitly configured', () => {
const manager = CacheManager.fromConfig(defaultConfig());
const expectedTtl = 3600;
const expectedNamespace = 'test-plugin';
manager
.forPlugin(expectedNamespace)
.getClient({ defaultTtl: expectedTtl });
const cache = (Keyv as unknown) as jest.Mock;
const mockCalls = cache.mock.calls.splice(-1);
const callArgs = mockCalls[0];
expect(callArgs[0]).toMatchObject({
ttl: expectedTtl,
namespace: expectedNamespace,
});
});
it('returns a memcache client when configured', () => {
const expectedHost = '127.0.0.1:11211';
const manager = CacheManager.fromConfig(
new ConfigReader({
backend: {
cache: {
store: 'memcache',
connection: expectedHost,
},
},
}),
);
const expectedTtl = 3600;
manager.forPlugin('test').getClient({ defaultTtl: expectedTtl });
const cache = (Keyv as unknown) as jest.Mock;
const mockCacheCalls = cache.mock.calls.splice(-1);
expect(mockCacheCalls[0][0]).toMatchObject({
ttl: expectedTtl,
});
expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvMemcache);
const memcache = KeyvMemcache as jest.Mock;
const mockMemcacheCalls = memcache.mock.calls.splice(-1);
expect(mockMemcacheCalls[0][0]).toEqual(expectedHost);
});
});
});
+114
View File
@@ -0,0 +1,114 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Config } from '@backstage/config';
import Keyv from 'keyv';
// @ts-expect-error
import KeyvMemcache from 'keyv-memcache';
import { DefaultCacheClient, CacheClient } from './CacheClient';
import { NoStore } from './NoStore';
import { PluginCacheManager } from './types';
/**
* Implements a Cache Manager which will automatically create new cache clients
* for plugins when requested. All requested cache clients are created with the
* connection details provided.
*/
export class CacheManager {
/**
* Keys represent supported `backend.cache.store` values, mapped to factories
* that return Keyv instances appropriate to the store.
*/
private readonly storeFactories = {
memcache: this.getMemcacheClient,
memory: this.getMemoryClient,
none: this.getNoneClient,
};
private readonly store: keyof CacheManager['storeFactories'];
private readonly connection: string;
/**
* Creates a new CacheManager instance by reading from the `backend` config
* section, specifically the `.cache` key.
*
* @param config The loaded application configuration.
*/
static fromConfig(config: Config): CacheManager {
// If no `backend.cache` config is provided, instantiate the CacheManager
// with a "NoStore" cache client.
const store = config.getOptionalString('backend.cache.store') || 'none';
const connectionString =
config.getOptionalString('backend.cache.connection') || '';
return new CacheManager(store, connectionString);
}
private constructor(store: string, connectionString: string) {
if (!this.storeFactories.hasOwnProperty(store)) {
throw new Error(`Unknown cache store: ${store}`);
}
this.store = store as keyof CacheManager['storeFactories'];
this.connection = connectionString;
}
/**
* Generates a CacheManagerInstance for consumption by plugins.
*
* @param pluginId The plugin that the cache manager should be created for. Plugin names should be unique.
*/
forPlugin(pluginId: string): PluginCacheManager {
return {
getClient: (opts = {}): CacheClient => {
const concreteClient = this.getClientWithTtl(pluginId, opts.defaultTtl);
return new DefaultCacheClient({
client: concreteClient,
});
},
};
}
private getClientWithTtl(pluginId: string, ttl: number | undefined): Keyv {
return this.storeFactories[this.store].call(this, pluginId, ttl);
}
private getMemcacheClient(
pluginId: string,
defaultTtl: number | undefined,
): Keyv {
return new Keyv({
namespace: pluginId,
ttl: defaultTtl,
store: new KeyvMemcache(this.connection),
});
}
private getMemoryClient(
pluginId: string,
defaultTtl: number | undefined,
): Keyv {
return new Keyv({
namespace: pluginId,
ttl: defaultTtl,
});
}
private getNoneClient(pluginId: string): Keyv {
return new Keyv({
namespace: pluginId,
store: new NoStore(),
});
}
}
+41
View File
@@ -0,0 +1,41 @@
/*
* Copyright 2021 Spotify AB
*
* 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.
*/
/**
* Storage class compatible with Keyv which always results in a no-op. This is
* used when no cache store is configured in a Backstage backend instance.
*/
export class NoStore extends Map<string, any> {
clear(): void {
return;
}
delete(_key: string): boolean {
return false;
}
get(_key: string) {
return;
}
has(_key: string): boolean {
return false;
}
set(_key: string, _value: any): this {
return this;
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2021 Spotify AB
*
* 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.
*/
export type { CacheClient } from './CacheClient';
export { CacheManager } from './CacheManager';
export type { PluginCacheManager } from './types';
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { CacheClient } from './CacheClient';
type ClientOptions = {
/**
* An optional default TTL (in milliseconds) to be set when getting a client
* instance. If not provided, data will persist indefinitely by default (or
* can be configured per entry at set-time).
*/
defaultTtl?: number;
};
/**
* The PluginCacheManager manages access to cache stores that Plugins get.
*/
export type PluginCacheManager = {
/**
* getClient provides backend plugins cache connections for itself.
*
* The purpose of this method is to allow plugins to get isolated data
* stores so that plugins are discouraged from cache-level integration
* and/or cache key collisions.
*/
getClient: (options?: ClientOptions) => CacheClient;
};
+1
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export * from './cache';
export * from './config';
export * from './database';
export * from './discovery';
+4 -1
View File
@@ -24,6 +24,7 @@
import Router from 'express-promise-router';
import {
CacheManager,
createServiceBuilder,
getRootLogger,
loadBackendConfig,
@@ -59,11 +60,13 @@ function makeCreateEnv(config: Config) {
root.info(`Created UrlReader ${reader}`);
const databaseManager = SingleConnectionDatabaseManager.fromConfig(config);
const cacheManager = CacheManager.fromConfig(config);
return (plugin: string): PluginEnvironment => {
const logger = root.child({ type: 'plugin', plugin });
const database = databaseManager.forPlugin(plugin);
return { logger, database, config, reader, discovery };
const cache = cacheManager.forPlugin(plugin);
return { logger, cache, database, config, reader, discovery };
};
}
+2
View File
@@ -17,6 +17,7 @@
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import {
PluginCacheManager,
PluginDatabaseManager,
PluginEndpointDiscovery,
UrlReader,
@@ -24,6 +25,7 @@ import {
export type PluginEnvironment = {
logger: Logger;
cache: PluginCacheManager;
database: PluginDatabaseManager;
config: Config;
reader: UrlReader;
+31 -6
View File
@@ -14428,9 +14428,9 @@ graphql-extensions@^0.12.8:
apollo-server-env "^3.0.0"
apollo-server-types "^0.6.3"
graphql-language-service-interface@2.8.2, graphql-language-service-interface@^2.8.2:
graphql-language-service-interface@^2.8.2:
version "2.8.2"
resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.8.2.tgz#b3bb2aef7eaf0dff0b4ea419fa412c5f66fa268b"
resolved "https://registry.yarnpkg.com/graphql-language-service-interface/-/graphql-language-service-interface-2.8.2.tgz#b3bb2aef7eaf0dff0b4ea419fa412c5f66fa268b"
integrity sha512-otbOQmhgkAJU1QJgQkMztNku6SbJLu/uodoFOYOOtJsizTjrMs93vkYaHCcYnLA3oi1Goj27XcHjMnRCYQOZXQ==
dependencies:
graphql-language-service-parser "^1.9.0"
@@ -14438,9 +14438,9 @@ graphql-language-service-interface@2.8.2, graphql-language-service-interface@^2.
graphql-language-service-utils "^2.5.1"
vscode-languageserver-types "^3.15.1"
graphql-language-service-parser@1.9.0, graphql-language-service-parser@^1.9.0:
graphql-language-service-parser@^1.9.0:
version "1.9.0"
resolved "https://registry.npmjs.org/graphql-language-service-parser/-/graphql-language-service-parser-1.9.0.tgz#79af21294119a0a7e81b6b994a1af36833bab724"
resolved "https://registry.yarnpkg.com/graphql-language-service-parser/-/graphql-language-service-parser-1.9.0.tgz#79af21294119a0a7e81b6b994a1af36833bab724"
integrity sha512-B5xPZLbBmIp0kHvpY1Z35I5DtPoDK9wGxQVRDIzcBaiIvAmlTrDvjo3bu7vKREdjFbYKvWNgrEWENuprMbF17Q==
dependencies:
graphql-language-service-types "^1.8.0"
@@ -16870,7 +16870,7 @@ json-buffer@3.0.0:
resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
json-buffer@3.0.1:
json-buffer@3.0.1, json-buffer@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
@@ -17222,6 +17222,14 @@ keytar@^5.4.0:
nan "2.14.1"
prebuild-install "5.3.3"
keyv-memcache@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/keyv-memcache/-/keyv-memcache-1.2.5.tgz#9097af5c617dc740e7300ebfd4a2efb8917429e3"
integrity sha512-iG+GHlhXyV83gmlCtMFIqNYVvv0i0towAy42NvziUR7D+k9jp81fAq0lu66H4gooOnW+ojkdigRvRpL40j1f7w==
dependencies:
json-buffer "^3.0.1"
memjs "^1.3.0"
keyv@^3.0.0:
version "3.1.0"
resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
@@ -17236,6 +17244,13 @@ keyv@^4.0.0:
dependencies:
json-buffer "3.0.1"
keyv@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.0.3.tgz#4f3aa98de254803cafcd2896734108daa35e4254"
integrity sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==
dependencies:
json-buffer "3.0.1"
killable@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
@@ -18309,6 +18324,11 @@ media-typer@0.3.0:
resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
memjs@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/memjs/-/memjs-1.3.0.tgz#b7959b4ff3770e4c785463fd147f1e4fafd47a24"
integrity sha512-y/V9a0auepA9Lgyr4QieK6K2FczjHucEdTpSS+hHVNmVEkYxruXhkHu8n6DSRQ4HXHEE3cc6Sf9f88WCJXGXsQ==
memoize-one@^5.1.1:
version "5.1.1"
resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
@@ -25699,11 +25719,16 @@ typescript-json-schema@^0.49.0:
typescript "^4.1.3"
yargs "^16.2.0"
typescript@^4.0.3, typescript@^4.1.3, typescript@~4.1.3:
typescript@^4.0.3, typescript@^4.1.3:
version "4.2.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3"
integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==
typescript@~4.1.3:
version "4.1.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.5.tgz#123a3b214aaff3be32926f0d8f1f6e704eb89a72"
integrity sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==
ua-parser-js@^0.7.18:
version "0.7.28"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"