Migrating between environments
This article presents a Node.js example of an application that migrates documents and users between two CKEditor Cloud Services environments, using the Documents API to export the data from the source environment and import it into the destination environment, and the Users API to migrate the users.
For an overview of the feature, its use cases (migrating between organizations, On-Premises instances, or cloud regions), and what can and cannot be migrated, see the Data migration guide.
Remember to provide a correct API secret for each environment and to generate a proper request signature for every request. The signature has to be generated with the secret of the environment the request is sent to.
This example does not require any external dependencies. It relies only on native Node.js features: the core crypto module to generate the request signature and the global fetch function to send the requests.
The global fetch function is available in Node.js 18 and later.
The following standalone Node.js script exports a document (together with its comments, suggestions, and revisions) from the source environment, migrates the required users, and imports everything into the destination environment. The endpoints in the example migrate data from an EU environment to a US environment, but you can point them at any pair of environments.
- Run the following commands:
mkdir cs-environment-migration-example && cd cs-environment-migration-example && touch migrateBetweenEnvironments.js
- Open
cs-environment-migration-example/migrateBetweenEnvironments.jsand paste the following code snippet:
const crypto = require( 'crypto' );
// The source environment - the one you migrate data FROM (in this example, an EU region environment).
// EU environments use the `*.cke-cs-eu.com` domain, US environments use the `*.cke-cs.com` domain.
const source = {
applicationEndpoint: 'https://SOURCE.cke-cs-eu.com', // The source environment endpoint.
environmentId: 'sourceEnvironmentId', // Type your source environment ID here.
apiSecret: 'SOURCE_SECRET' // Do not forget to hide this value in a safe place e.g. an .env file!
};
// The destination environment - the one you migrate data TO (in this example, a US region environment).
// To migrate in the opposite direction, simply swap the `source` and `destination` configuration.
const destination = {
applicationEndpoint: 'https://DESTINATION.cke-cs.com', // The destination environment endpoint.
environmentId: 'destinationEnvironmentId', // Type your destination environment ID here.
apiSecret: 'DESTINATION_SECRET', // Do not forget to hide this value in a safe place e.g. an .env file!
bundleVersion: '1.0.0' // The version of the editor bundle uploaded to the destination environment.
};
// The ID of the document to migrate.
const documentId = 'my_document_id';
// The users to migrate to the destination environment. Migrate all users that authored
// the documents you move, so that comment and suggestion authors can be found.
const users = [
{ id: 'user-1', email: 'user-1@example.com', name: 'Example user' }
];
( async () => {
// 1. Export the document from the source environment.
const exportedData = await exportDocument();
if ( !exportedData ) {
return;
}
// 2. Migrate the users to the destination environment.
// Users are stored per environment, so this only needs to be done once per
// destination environment - not for every migrated document.
await migrateUsers();
// 3. Import the document into the destination environment.
await importDocument( mapExportedDocument( documentId, exportedData ) );
console.log( `Document "${ documentId }" has been migrated to the destination environment.` );
} )();
// Exports the whole document with all its data from the source environment.
function exportDocument() {
const url = `${ source.applicationEndpoint }/api/v5/${ source.environmentId }/documents/${ documentId }`;
return sendRequest( source, 'GET', url );
}
// Creates the users in the destination environment. Users that already exist are skipped.
async function migrateUsers() {
const url = `${ destination.applicationEndpoint }/api/v5/${ destination.environmentId }/users`;
for ( const user of users ) {
await sendRequest( destination, 'POST', url, user );
}
}
// Imports the whole document with all its data into the destination environment.
function importDocument( body ) {
const url = `${ destination.applicationEndpoint }/api/v5/${ destination.environmentId }/documents`;
return sendRequest( destination, 'POST', url, body );
}
// Maps the data exported from the source environment to the structure expected by the import endpoint.
// The exported document is reused as a single resource - only the bundle version of the
// destination environment needs to be provided.
function mapExportedDocument( documentId, exportedData ) {
return {
id: documentId,
content: {
bundle_version: destination.bundleVersion,
data: exportedData.content.data,
version: exportedData.content.version
},
comments: exportedData.comments,
suggestions: exportedData.suggestions,
revisions: exportedData.revisions,
threads: exportedData.threads
};
}
// Sends a signed request to the CKEditor Cloud Services API of the given environment.
async function sendRequest( environment, method, url, body ) {
const CSTimestamp = Date.now();
const options = {
method,
headers: {
'Content-Type': 'application/json',
'X-CS-Timestamp': CSTimestamp,
'X-CS-Signature': generateSignature( environment.apiSecret, method, url, CSTimestamp, body )
}
};
if ( method.toUpperCase() !== 'GET' ) {
options.body = JSON.stringify( body );
}
let response;
try {
response = await fetch( url, options );
} catch ( error ) {
console.log( `${ method } ${ url } -> request failed:`, error );
return null;
}
const data = await response.json().catch( () => null );
console.log( `${ method } ${ url } -> ${ response.status }` );
if ( !response.ok ) {
console.log( data );
return null;
}
return data ?? true;
}
// See: https://ckeditor.com/docs/cs/latest/examples/security/request-signature-nodejs.html.
function generateSignature( secret, method, uri, timestamp, body ) {
const url = new URL( uri );
const path = url.pathname + url.search;
const hmac = crypto.createHmac( 'SHA256', secret );
hmac.update( `${ method.toUpperCase() }${ path }${ timestamp }` );
if ( body ) {
hmac.update( Buffer.from( JSON.stringify( body ) ) );
}
return hmac.digest( 'hex' );
}
-
Update the
sourceanddestinationcredentials, thedocumentId, and theusersvalues in the code snippet. -
Execute the migration:
node migrateBetweenEnvironments.js
Before running the script, make sure that:
- You have two environments – the source environment you migrate data from and the destination environment you migrate data to.
- An editor bundle is uploaded to the destination environment. Set
destination.bundleVersionto the version of that bundle. - The migrated document does not exist in an active collaboration session or in the document storage of the destination environment, and its related comments and suggestions do not exist in the destination database yet.
After a successful response, the document and its data are available in the destination environment. Migration is done document by document, so to migrate more documents repeat the export and import steps for every document ID. To migrate data in the opposite direction, swap the source and destination configuration.
Migrate documents in small batches (a few to a dozen at a time) and verify that each one migrated correctly before continuing. Avoid migrating dozens of documents at once, especially on On-Premises instances, as the process is resource-intensive. See the Data migration guide for details.
In case any operation during importing fails, all data of the imported document will be rolled back. We suggest enabling the Insights Panel, where you will find detailed logs from the whole process, including the exact reasons for any failed requests.
The import requires the editor bundle used by your documents to be present in the destination environment. The bundle is not moved automatically, but you can migrate it over the REST API – download the bundle and its configuration from the source environment and upload both to the destination:
- Download the bundle JavaScript with
GET /editors/{bundle_version}– this endpoint returns raw JavaScript. - Download its configuration with
GET /editors/{bundle_version}/config. - Upload both to the destination with
POST /editors.
Run this once per destination environment, before migrating documents. The snippet below reuses the source, destination, sendRequest(), and generateSignature() definitions from the example above.
// The version of the editor bundle to migrate. It has to be the version your
// documents were created with, and the same version is used in the destination.
const bundleVersion = destination.bundleVersion;
async function migrateEditorBundle() {
// 1. Download the bundle (raw JavaScript) and its configuration from the source.
const bundle = await getEditorBundle( source, bundleVersion );
const { config, is_multi_root_editor } = await getEditorConfig( source, bundleVersion );
// 2. Upload both to the destination under the same version. The configuration
// already carries the bundle version, so it is reused as-is.
const url = `${ destination.applicationEndpoint }/api/v5/${ destination.environmentId }/editors`;
return sendRequest( destination, 'POST', url, { bundle, config, is_multi_root_editor } );
}
// `GET /editors/{bundle_version}` returns raw JavaScript, so the response is read
// as text instead of JSON. The request is signed the same way as the others.
async function getEditorBundle( environment, version ) {
const url = `${ environment.applicationEndpoint }/api/v5/${ environment.environmentId }/editors/${ version }`;
const CSTimestamp = Date.now();
const response = await fetch( url, {
headers: {
'X-CS-Timestamp': CSTimestamp,
'X-CS-Signature': generateSignature( environment.apiSecret, 'GET', url, CSTimestamp )
}
} );
return response.text();
}
// `GET /editors/{bundle_version}/config` returns JSON: { config, is_multi_root_editor }.
function getEditorConfig( environment, version ) {
const url = `${ environment.applicationEndpoint }/api/v5/${ environment.environmentId }/editors/${ version }/config`;
return sendRequest( environment, 'GET', url );
}
migrateEditorBundle();
There is no endpoint that migrates all documents at once, so a full migration means iterating over the document IDs and migrating them one by one. How you obtain that list depends on whether the source environment uses the document storage:
- With document storage – list the stored documents with
GET /storage. This endpoint is paginated, so the snippet below follows thenext_cursorto collect every page. - Without document storage – list the documents that currently have an active collaboration session with
GET /collaborations. See Migrating content without document storage below.
The snippet below reuses the source, destination, sendRequest(), generateSignature(), mapExportedDocument(), and users definitions from the example above. It migrates the users once and then processes the documents in batches, stopping if any document fails so that you can investigate before continuing.
Keep BATCH_SIZE low (a few to a dozen) and verify each batch before continuing. Avoid migrating dozens of documents at once, especially on On-Premises instances, as the export and import process is resource-intensive.
If you already know which documents to migrate, you can skip the listing and iterate over your own list of IDs instead.
// The number of documents to migrate before pausing for verification. Keep this low.
const BATCH_SIZE = 10;
// Lists the IDs of all documents saved in the source environment's document storage.
// `GET /storage` is paginated, so follow `next_cursor` until all pages are collected.
async function listSourceDocumentIds() {
const baseUrl = `${ source.applicationEndpoint }/api/v5/${ source.environmentId }/storage`;
const ids = [];
let cursor = null;
do {
const url = cursor ? `${ baseUrl }?cursor=${ encodeURIComponent( cursor ) }` : baseUrl;
const page = await sendRequest( source, 'GET', url );
if ( !page ) {
break;
}
// Each item also has an `is_stored_properly` flag indicating whether the stored content is
// complete. Here all documents are migrated, but you can filter on it to skip broken ones.
ids.push( ...page.data.map( document => document.id ) );
cursor = page.next_cursor;
} while ( cursor );
return ids;
}
// Migrates a single document and returns whether it succeeded.
async function migrateDocument( documentId ) {
const exportUrl = `${ source.applicationEndpoint }/api/v5/${ source.environmentId }/documents/${ documentId }`;
const exportedData = await sendRequest( source, 'GET', exportUrl );
if ( !exportedData ) {
return false;
}
const importUrl = `${ destination.applicationEndpoint }/api/v5/${ destination.environmentId }/documents`;
const imported = await sendRequest( destination, 'POST', importUrl, mapExportedDocument( documentId, exportedData ) );
return Boolean( imported );
}
( async () => {
const documentIds = await listSourceDocumentIds();
console.log( `Found ${ documentIds.length } document(s) to migrate.` );
// Migrate the users once for the whole environment - they are shared by all documents.
await migrateUsers();
const failed = [];
for ( let i = 0; i < documentIds.length; i += BATCH_SIZE ) {
const batch = documentIds.slice( i, i + BATCH_SIZE );
console.log( `Migrating documents ${ i + 1 }-${ i + batch.length } of ${ documentIds.length }...` );
// Migrate the batch one document at a time to avoid overloading the server.
for ( const documentId of batch ) {
const success = await migrateDocument( documentId );
if ( !success ) {
failed.push( documentId );
}
}
// Stop on the first failure so you can verify what has been migrated so far.
if ( failed.length ) {
console.log( `Migration stopped. Failed document(s): ${ failed.join( ', ' ) }` );
return;
}
console.log( `Batch complete. Verify the migrated documents before continuing.` );
}
console.log( 'All documents have been migrated.' );
} )();
This snippet migrates the users listed in the users array. When migrating an entire environment, make sure that array contains every author of the migrated documents, so that comment and suggestion authors can be found in the destination environment. See the Data migration guide for more details.
If the source environment does not use the document storage, documents are not persisted – a document exists only while its collaboration session is active. In this case you cannot list documents with GET /storage. Instead, list the documents that currently have an active collaboration session with GET /collaborations and migrate them while their sessions are still active.
A document whose collaboration session has already ended and was not saved to the document storage cannot be exported, so it cannot be migrated. Make sure the sessions of the documents you want to migrate are active before you start.
To use this approach, replace listSourceDocumentIds() in the snippet above with the following function. The rest of the migration flow stays the same.
// Lists the IDs of the documents that currently have an active collaboration session
// in the source environment. Use this when the document storage is not enabled.
// `GET /collaborations` returns a plain array of document IDs.
async function listSourceDocumentIds() {
const url = `${ source.applicationEndpoint }/api/v5/${ source.environmentId }/collaborations`;
return await sendRequest( source, 'GET', url ) ?? [];
}