Add basis for strapi/data-transfer core package + transfer engine

This commit is contained in:
Convly 2022-10-13 11:01:35 +02:00
parent fee711c4e2
commit f1ecdabd3c
11 changed files with 742 additions and 0 deletions

View File

@ -0,0 +1,22 @@
Copyright (c) 2015-present Strapi Solutions SAS
Portions of the Strapi software are licensed as follows:
- All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE".
- All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below.
MIT Expat License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,212 @@
import { pipeline } from 'stream';
import { chain } from 'stream-chain';
import {
IDestinationProvider,
ISourceProvider,
ITransferEngine,
ITransferEngineOptions,
} from '../../types';
export class TransferEngine implements ITransferEngine {
sourceProvider: ISourceProvider;
destinationProvider: IDestinationProvider;
options: ITransferEngineOptions;
constructor(
sourceProvider: ISourceProvider,
destinationProvider: IDestinationProvider,
options: ITransferEngineOptions
) {
this.sourceProvider = sourceProvider;
this.destinationProvider = destinationProvider;
this.options = options;
}
private assertStrapiVersionIntegrity(sourceVersion?: string, destinationVersion?: string) {
const strategy = this.options.versionMatching;
if (!sourceVersion || !destinationVersion) {
return;
}
if (strategy === 'ignore') {
return;
}
if (strategy === 'exact' && sourceVersion === destinationVersion) {
return;
}
const sourceTokens = sourceVersion.split('.');
const destinationTokens = destinationVersion.split('.');
const [major, minor, patch] = sourceTokens.map(
(value, index) => value === destinationTokens[index]
);
if (
(strategy === 'major' && major) ||
(strategy === 'minor' && major && minor) ||
(strategy === 'patch' && major && minor && patch)
) {
return;
}
throw new Error(
`Strapi versions doesn't match (${strategy} check): ${sourceVersion} does not match with ${destinationVersion} `
);
}
async boostrap(): Promise<void> {
await Promise.all([
// bootstrap source provider
this.sourceProvider.bootstrap?.(),
// bootstrap destination provider
this.destinationProvider.bootstrap?.(),
]);
}
async close(): Promise<void> {
await Promise.all([
// close source provider
this.sourceProvider.close?.(),
// close destination provider
this.destinationProvider.close?.(),
]);
}
async integrityCheck(): Promise<boolean> {
const sourceMetadata = await this.sourceProvider.getMetadata();
const destinationMetadata = await this.destinationProvider.getMetadata();
if (!sourceMetadata || !destinationMetadata) {
return true;
}
try {
// Version check
this.assertStrapiVersionIntegrity(
sourceMetadata?.strapi?.version,
destinationMetadata?.strapi?.version
);
return true;
} catch (error) {
if (error instanceof Error) {
console.error('Integrity checks failed:', error.message);
}
return false;
}
}
async transfer(): Promise<void> {
try {
await this.boostrap();
const isValidTransfer = await this.integrityCheck();
if (!isValidTransfer) {
throw new Error(
`Unable to transfer the data between ${this.sourceProvider.name} and ${this.destinationProvider.name}.\nPlease refer to the log above for more information.`
);
}
await this.transferEntities();
// NOTE: to split into multiple steps
// entities <> links <> files
// do we need to ignore files from transferEntities & transferLinks?
await this.transferMedia();
await this.transferLinks();
await this.transferConfiguration();
await this.close();
} catch (e) {
console.log('error', e);
// Rollback the destination provider if an exception is thrown during the transfer
// Note: This will be configurable in the future
// await this.destinationProvider?.rollback(e);
}
}
async transferEntities(): Promise<void> {
// const inStream = await this.sourceProvider.streamEntities?.();
// const outStream = await this.destinationProvider.getEntitiesStream?.();
// if (!inStream || !outStream) {
// console.log('Unable to transfer entities, one of the stream is missing');
// return;
// }
// return new Promise((resolve, reject) => {
// pipeline(
// // We might want to use a json-chain's Chain here since they allow transforms
// // streams as regular functions (that allows object as parameter & return type)
// inStream,
// // chain([
// // (data) => {
// // console.log('hello', data);
// // return data;
// // },
// // ]),
// outStream,
// (e: NodeJS.ErrnoException | null, value: unknown) => {
// if (e) {
// console.log('Something wrong happened', e);
// reject(e);
// return;
// }
// console.log('value', value);
// console.log('All the entities have been transferred');
// resolve();
// }
// );
// });
}
async transferLinks(): Promise<void> {
// const inStream = await this.sourceProvider.streamLinks?.();
// const outStream = await this.destinationProvider.getLinksStream?.();
// if (!inStream || !outStream) {
// console.log('Unable to transfer links, one of the stream is missing');
// return;
// }
// return new Promise((resolve, reject) => {
// pipeline(
// // We might want to use a json-chain's Chain here since they allow transforms
// // streams as regular functions (that allows object as parameter & return type)
// inStream as any,
// // chain([
// // (data) => {
// // console.log('hello', data);
// // return data;
// // },
// // ]),
// outStream as any,
// (e: Error) => {
// if (e) {
// console.log('Something wrong happened', e);
// reject(e);
// return;
// }
// console.log('All the links have been transferred');
// resolve();
// }
// );
// });
return new Promise((resolve) => resolve());
}
async transferMedia(): Promise<void> {
console.log('transferMedia not yet implemented');
return new Promise((resolve) => resolve());
}
async transferConfiguration(): Promise<void> {
console.log('transferConfiguration not yet implemented');
return new Promise((resolve) => resolve());
}
}

View File

View File

@ -0,0 +1,53 @@
{
"name": "@strapi/data-transfer",
"version": "4.4.3",
"description": "Data transfer capabilities for Strapi",
"keywords": [
"strapi",
"data",
"transfer",
"import",
"export",
"backup",
"restore"
],
"license": "SEE LICENSE IN LICENSE",
"author": {
"name": "Strapi Solutions SAS",
"email": "hi@strapi.io",
"url": "https://strapi.io"
},
"maintainers": [
{
"name": "Strapi Solutions SAS",
"email": "hi@strapi.io",
"url": "https://strapi.io"
}
],
"main": "./dist/index.js",
"types": "./src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublish": "tsc -p tsconfig.json"
},
"directories": {
"lib": "./dist"
},
"dependencies": {
"@strapi/logger": "4.4.3",
"chalk": "4.1.2",
"prettier": "2.7.1",
"stream-chain": "2.2.5",
"stream-json": "1.7.4",
"tar": "6.1.11"
},
"devDependencies": {
"@tsconfig/node16": "1.0.3",
"@types/stream-chain": "2.0.1",
"typescript": "4.8.4"
},
"engines": {
"node": ">=14.19.1 <=18.x.x",
"npm": ">=6.0.0"
}
}

View File

@ -0,0 +1,11 @@
{
"extends": "@tsconfig/node16/tsconfig.json",
"compilerOptions": {
"strict": true,
"lib": ["ESNEXT"],
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["types", "lib/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,215 @@
import { GetAttributesValues } from '@strapi/strapi';
import { SchemaUID } from '@strapi/strapi/lib/types/utils';
export interface IMetadata {
strapi?: {
version?: string;
plugins?: {
name: string;
version: string;
}[];
};
createdAt?: string;
}
/**
* Common TransferEngine format to represent a Strapi entity
* @template T The schema UID this entity represents
*/
export interface IEntity<T extends SchemaUID> {
/**
* UID of the parent type (content-type, component, etc...)
*/
type: T;
/**
* Reference of the entity.
* Might be deprecated and replaced by a "ref" or "reference" property in the future
*/
id: number | string;
/**
* The entity data (attributes value)
*/
data: GetAttributesValues<T>;
}
/**
* Union type that regroups all the different kinds of link
*/
export type ILink = IBasicLink | IMorphLink | ICircularLink | IComponentLink | IDynamicZoneLink;
/**
* Default generic link structure
*/
interface IDefaultLink {
/**
* The link type
* (useful for providers (destination) to adapt the logic following what kind of link is processed)
*/
kind: string;
/**
* Left side of the link
* It should hold information about the entity that owns the dominant side of the link
*/
left: {
/**
* Entity UID
* (restricted to content type)
*/
type: Strapi.ContentTypeUIDs;
/**
* Reference ID of the entity
*/
ref: number | string;
};
/**
* Right side of the link
* It should hold information about the entity attached to the left side of the link
*/
right: {
/**
* Entity UID
* (can be a content type or a component)
*/
type: SchemaUID;
/**
* Reference ID of the entity
*/
ref: number | string;
};
}
/**
* Basic link between two content type entities
*/
interface IBasicLink extends IDefaultLink {
kind: 'relation.basic';
right: {
/**
* The right side of a relation.basic link must be a content type
*/
type: Strapi.ContentTypeUIDs;
/**
* Reference ID of the entity
*/
ref: number | string;
};
}
/**
* Polymorphic link (one source content type to multiple different content types)
*/
interface IMorphLink extends IDefaultLink {
kind: 'relation.morph';
right: {
/**
* The right side of a relation.morph link must be a content type
*/
type: Strapi.ContentTypeUIDs;
/**
* Reference ID of the target entity
*/
ref: number | string;
/**
* The target attribute used to hold the value
*/
attribute: string;
/**
* Can contain the link's position (relative to other similar links)
*/
order: number;
};
}
/**
* Regular link with the left and right sides having the save content-type
*/
interface ICircularLink extends IDefaultLink {
kind: 'relation.circular';
}
/**
* Link from a content type to a component
*/
interface IComponentLink extends IDefaultLink {
kind: 'component.basic';
right: {
/**
* The right side of the link must be a component
*/
type: Strapi.ComponentUIDs;
/**
* Reference ID of the component
*/
ref: number | string;
/**
* The attribute used to hold the link value in the component
*/
attribute: string;
/**
* Can contain the link's position (relative to other similar links)
*/
order: number;
};
}
/**
* Link from a content type to a dynamic zone
* Very similar to the component link but with a different name
*/
interface IDynamicZoneLink extends IDefaultLink {
kind: 'dynamiczone.basic';
right: {
/**
* The right side of the link must be a component
*/
type: Strapi.ComponentUIDs;
/**
* Reference ID of the component
*/
ref: number | string;
/**
* The attribute used to hold the link value in the component
*/
attribute: string;
/**
* MUST contain the link's position relative to other links
* bound to the same dynamic zone (aka. left side of the link)
*/
order: number;
};
}
/**
* Represent a piece of a media file
*
* /!\ Draft Version /!\
*
* Note: even individual media will probably get streamed chunk by chunk,
* we need a way to identify to which entity they're related to.
*
* Also, it might get tricky to apply specific transformations to media as a whole
*/
export interface IMedia {
/**
* The media mime type
*/
type: 'png' | 'pdf'; // | ... | ...
/**
* Reference ID for the media
*/
ref: number | string;
/**
* Data chunk (as a buffer) that contains a part of the file
*/
chunk: Buffer | Buffer[];
}

View File

@ -0,0 +1,4 @@
export * from './common-entities';
export * from './providers';
export * from './transfer-engine';
export * from './utils';

View File

@ -0,0 +1,34 @@
import { Stream } from './utils';
import { IMetadata } from './common-entities';
type ProviderType = 'source' | 'destination';
interface IProvider {
type: ProviderType;
name: string;
bootstrap?(): Promise<void> | void;
close?(): Promise<void> | void;
getMetadata(): IMetadata | Promise<IMetadata>;
}
export interface ISourceProvider extends IProvider {
// Getters for the source's transfer streams
streamEntities?(): Stream | Promise<Stream>;
streamLinks?(): Stream | Promise<Stream>;
streamMedia?(): Stream | Promise<Stream>;
streamConfiguration?(): Stream | Promise<Stream>;
}
export interface IDestinationProvider extends IProvider {
/**
* Optional rollback implementation
*/
rollback?<T extends Error = Error>(e: T): void | Promise<void>;
// Getters for the destination's transfer streams
getEntitiesStream?(): Stream | Promise<Stream>;
getLinksStream?(): Stream | Promise<Stream>;
getMediaStream?(): Stream | Promise<Stream>;
getConfigurationStream?(): Stream | Promise<Stream>;
}

View File

@ -0,0 +1,135 @@
import { SchemaUID } from '@strapi/strapi/lib/types/utils';
import { IEntity, ILink, IMedia } from './common-entities';
import { ITransferRule } from './utils';
import { ISourceProvider, IDestinationProvider } from './provider';
/**
* Defines the capabilities and properties of the transfer engine
*/
export interface ITransferEngine {
/**
* Provider used as a source which that will stream its data to the transfer engine
*/
sourceProvider: ISourceProvider;
/**
* Provider used as a destination that will receive its data from the transfer engine
*/
destinationProvider: IDestinationProvider;
/**
* The options used to customize the behavio of the transfer engine
*/
options: ITransferEngineOptions;
/**
* Runs the integrity check which will make sure it's possible
* to transfer data from the source to the provider.
*
* Note: It requires to read the content of the source & destination metadata files
*/
integrityCheck(): Promise<boolean>;
/**
* Start streaming selected data from the source to the destination
*/
transfer(): Promise<void>;
/**
* Run the bootstrap lifecycle method of each provider
*
* Note: The bootstrap method can be used to initialize database
* connections, open files, etc...
*/
boostrap(): Promise<void>;
/**
* Run the close lifecycle method of each provider
*
* Note: The close method can be used to gracefully close connections, cleanup the filesystem, etc..
*/
close(): Promise<void>;
/**
* Start the entities transfer by connecting the
* related source and destination providers streams
*/
transferEntities(): Promise<void>;
/**
* Start the links transfer by connecting the
* related source and destination providers streams
*/
transferLinks(): Promise<void>;
/**
* Start the media transfer by connecting the
* related source and destination providers streams
*/
transferMedia(): Promise<void>;
/**
* Start the configuration transfer by connecting the
* related source and destination providers streams
*/
transferConfiguration(): Promise<void>;
}
/**
* Options used to customize the TransferEngine behavior
*
* Note: Please add your suggestions. Also, we'll need to consider matching what is
* written for the CLI with those options at one point
*
* Note: here, we're listing the TransferEngine options, not the individual providers' options
*/
export interface ITransferEngineOptions {
/**
* The strategy to use when importing the data from the source to the destination
* Note: Should we keep this here or fully delegate the strategies logic to the destination?
*/
strategy: 'restore' | 'merge';
/**
* What kind of version matching should be done between the source and the destination metadata?
* @example
* "exact" // must be a strict equality, whatever the format used
* "ignore" // do not check if versions match
* "major" // only the major version should match. 4.3.9 and 4.4.1 will work, while 3.3.2 and 4.3.2 won't
* "minor" // both the major and minor version should match. 4.3.9 and 4.3.11 will work, while 4.3.9 and 4.4.1 won't
* "patch" // every part of the version should match. Similar to "exact" but only work on semver.
*/
versionMatching: 'exact' | 'ignore' | 'major' | 'minor' | 'patch';
// List of global transform streams to integrate into the final pipelines
common?: {
rules?: ITransferRule[];
};
/**
* Options related to the transfer of the entities
*/
entities?: {
/**
* Transformation rules for entities
*/
rules?: ITransferRule<<T extends SchemaUID>(entity: IEntity<T>) => boolean>[];
};
/**
* Options related to the transfer of the links
*/
links?: {
/**
* Transformation rules for links
*/
rules?: ITransferRule<<T extends ILink>(link: T) => boolean>[];
};
/**
* Options related to the transfer of the links
*/
media?: {
/**
* Transformation rules for media chunks
*/
rules?: ITransferRule<<T extends IMedia>(media: T) => boolean>[];
};
}

View File

@ -0,0 +1,27 @@
import { Readable, Writable, Duplex, Transform } from 'stream';
/**
* Default signature for transfer rules' filter methods
*/
type TransferRuleFilterSignature = (...params: unknown[]) => boolean;
/**
* Define a transfer rule which will be used to intercept
* and potentially modify the transferred data
*/
export interface ITransferRule<
T extends TransferRuleFilterSignature = TransferRuleFilterSignature
> {
/**
* Filter method used to select which data should be transformed
*/
filter?: T;
/**
* Transform middlewares which will be applied to the filtered data
*/
transforms: StreamItem[];
}
export type TransformFunction = (chunk: any, encoding?: string) => any;
export type StreamItem = Stream | TransformFunction;
type Stream = Readable | Writable | Duplex | Transform;

View File

@ -5693,6 +5693,11 @@
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==
"@tsconfig/node16@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e"
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
"@types/accepts@*", "@types/accepts@^1.3.5": "@types/accepts@*", "@types/accepts@^1.3.5":
version "1.3.5" version "1.3.5"
resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
@ -6272,6 +6277,13 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
"@types/stream-chain@2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/stream-chain/-/stream-chain-2.0.1.tgz#4d3cc47a32609878bc188de0bae420bcfd3bf1f5"
integrity sha512-D+Id9XpcBpampptkegH7WMsEk6fUdf9LlCIX7UhLydILsqDin4L0QT7ryJR0oycwC7OqohIzdfcMHVZ34ezNGg==
dependencies:
"@types/node" "*"
"@types/tapable@^1", "@types/tapable@^1.0.5": "@types/tapable@^1", "@types/tapable@^1.0.5":
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.8.tgz#b94a4391c85666c7b73299fd3ad79d4faa435310" resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.8.tgz#b94a4391c85666c7b73299fd3ad79d4faa435310"
@ -20776,6 +20788,11 @@ stream-browserify@^3.0.0:
inherits "~2.0.4" inherits "~2.0.4"
readable-stream "^3.5.0" readable-stream "^3.5.0"
stream-chain@2.2.5, stream-chain@^2.2.5:
version "2.2.5"
resolved "https://registry.yarnpkg.com/stream-chain/-/stream-chain-2.2.5.tgz#b30967e8f14ee033c5b9a19bbe8a2cba90ba0d09"
integrity sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==
stream-each@^1.1.0: stream-each@^1.1.0:
version "1.2.3" version "1.2.3"
resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae" resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae"
@ -20805,6 +20822,13 @@ stream-http@^3.2.0:
readable-stream "^3.6.0" readable-stream "^3.6.0"
xtend "^4.0.2" xtend "^4.0.2"
stream-json@1.7.4:
version "1.7.4"
resolved "https://registry.yarnpkg.com/stream-json/-/stream-json-1.7.4.tgz#e41637f93c5aca7267009ca8a3f6751e62331e69"
integrity sha512-ja2dde1v7dOlx5/vmavn8kLrxvNfs7r2oNc5DYmNJzayDDdudyCSuTB1gFjH4XBVTIwxiMxL4i059HX+ZiouXg==
dependencies:
stream-chain "^2.2.5"
stream-shift@^1.0.0: stream-shift@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
@ -21860,6 +21884,11 @@ typescript@4.6.2:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4"
integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg== integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==
typescript@4.8.4:
version "4.8.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==
typescript@^4.6.2: typescript@^4.6.2:
version "4.7.4" version "4.7.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"