Merge branch 'deits/architecture-rework' into deits/transfer-protocol

This commit is contained in:
Convly 2023-01-03 10:35:29 +01:00
commit 60d8434fad
78 changed files with 450 additions and 393 deletions

View File

@ -1,7 +1,6 @@
'use strict';
// eslint-disable-next-line import/no-unresolved, node/no-missing-require
const { register: registerDataTransfer } = require('@strapi/data-transfer');
const { register: registerDataTransfer } = require('@strapi/data-transfer/lib/strapi');
const registerAdminPanelRoute = require('./routes/serve-admin-panel');
const adminAuthStrategy = require('./strategies/admin');

View File

@ -0,0 +1 @@
lib

View File

@ -0,0 +1 @@
lib/

View File

@ -1,9 +1,9 @@
#############################
# DATA TRANSFER X
# DATA TRANSFER
############################
*.js.map
lib/
src/
types/
tsconfig.json
@ -19,14 +19,12 @@ Icon
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
@ -40,7 +38,6 @@ $RECYCLE.BIN/
*.msm
*.msp
############################
# Packages
############################
@ -69,7 +66,6 @@ $RECYCLE.BIN/
*.out
*.pid
############################
# Logs and databases
############################
@ -79,7 +75,6 @@ $RECYCLE.BIN/
*.sql
*.sqlite
############################
# Misc.
############################
@ -89,7 +84,6 @@ $RECYCLE.BIN/
nbproject
.vscode/
############################
# Node.js
############################
@ -107,7 +101,6 @@ package-lock.json
!docs/package-lock.json
*.heapsnapshot
############################
# Tests
############################

View File

@ -1,2 +0,0 @@
export * from './push';
export { default as createTransferController } from './transfer';

View File

@ -1,4 +0,0 @@
export * from './engine';
export * from './providers';
export { default as register } from './register';

View File

@ -1,32 +0,0 @@
import { createTransferController } from '../../bootstrap/controllers';
import register from '../../register';
afterEach(() => {
jest.clearAllMocks();
});
const strapiMock = {
admin: {
routes: {
push: jest.fn(),
},
},
};
jest.mock('../../bootstrap/controllers', () => ({
createTransferController: jest.fn(),
}));
describe('Register the Transfer route', () => {
test('registers the /transfer route', () => {
register(strapiMock);
expect(strapiMock.admin.routes.push).toHaveBeenCalledWith({
method: 'GET',
path: '/transfer',
handler: createTransferController(),
config: {
auth: false,
},
});
});
});

View File

@ -1,9 +0,0 @@
// source providers
export * from './local-file-source-provider';
export * from './local-strapi-source-provider';
// destination providers
export * from './local-file-destination-provider';
export * from './local-strapi-destination-provider';
export * from './remote-strapi-destination-provider';

View File

@ -1 +0,0 @@
export * as strapi from './strapi';

View File

@ -1,83 +0,0 @@
import { Readable } from 'stream';
/**
* Collect every entity in a Readable stream
*/
export const collect = <T = unknown>(stream: Readable): Promise<T[]> => {
const chunks: T[] = [];
return new Promise((resolve, reject) => {
stream
.on('data', (chunk) => chunks.push(chunk))
.on('close', () => resolve(chunks))
.on('error', reject);
});
};
/**
* Create a "Strapi" like object factory based on the
* given params and cast it to the correct type
*/
export const getStrapiFactory =
<
T extends {
[key in keyof Partial<Strapi.Strapi>]: unknown;
}
>(
properties?: T
) =>
() => {
return { ...properties } as Strapi.Strapi;
};
/**
* Union type used to represent the default content types available
*/
export type ContentType = 'foo' | 'bar';
/**
* Factory to get default content types test values
*/
export const getContentTypes = (): {
[key in ContentType]: { uid: key; attributes: { [attribute: string]: unknown } };
} => ({
foo: { uid: 'foo', attributes: { title: { type: 'string' } } },
bar: { uid: 'bar', attributes: { age: { type: 'number' } } },
});
/**
* Create a factory of readable streams (wrapped with a jest mock function)
*/
export const createMockedReadableFactory = <T extends string = ContentType>(source: {
[ct in T]: Array<{ id: number; [key: string]: unknown }>;
}) =>
jest.fn((uid: T) => {
return Readable.from(source[uid] || []);
});
/**
* Create a factory of mocked query builders
*/
export const createMockedQueryBuilder = <T extends string = ContentType>(data: {
[key in T]: unknown[];
}) =>
jest.fn((uid: T) => {
const state: { [key: string]: unknown } = { populate: undefined };
return {
populate(populate: unknown) {
state.populate = populate;
return this;
},
stream() {
return Readable.from(data[uid]);
},
};
});
/**
* Update the global store with the given strapi value
*/
export const setGlobalStrapi = (strapi: Strapi.Strapi): void => {
(global as unknown as Global).strapi = strapi;
};

View File

@ -1,16 +0,0 @@
import { createTransferController } from './bootstrap/controllers';
const registerTransferRoute = (strapi: any) => {
strapi.admin.routes.push({
method: 'GET',
path: '/transfer',
handler: createTransferController(),
config: { auth: false },
});
};
const register = (strapi: any) => {
registerTransferRoute(strapi);
};
export default register;

View File

@ -1,20 +0,0 @@
import type { Schema } from '@strapi/strapi';
import { mapValues, pick } from 'lodash/fp';
const schemaSelectedKeys = [
'collectionName',
'info',
'options',
'pluginOptions',
'attributes',
'kind',
'modelType',
'modelName',
'uid',
'plugin',
'globalId',
];
export const mapSchemasValues = (schemas: Record<string, Schema>) => {
return mapValues(pick(schemaSelectedKeys), schemas);
};

View File

@ -1,48 +0,0 @@
import { Transform, Readable } from 'stream';
type TransformOptions = ConstructorParameters<typeof Transform>[0];
export const filter = <T>(
predicate: (value: T) => boolean | Promise<boolean>,
options: TransformOptions = { objectMode: true }
): Transform => {
return new Transform({
...options,
async transform(chunk, _encoding, callback) {
const keep = await predicate(chunk);
callback(null, keep ? chunk : undefined);
},
});
};
export const map = <T>(
predicate: (value: T) => T | Promise<T>,
options: TransformOptions = { objectMode: true }
): Transform => {
return new Transform({
...options,
async transform(chunk, _encoding, callback) {
const mappedValue = await predicate(chunk);
callback(null, mappedValue);
},
});
};
/**
* Collect every entity in a Readable stream
*/
export const collect = <T = unknown>(stream: Readable): Promise<T[]> => {
const chunks: T[] = [];
return new Promise((resolve) => {
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('end', () => {
stream.destroy();
resolve(chunks);
});
});
};

View File

@ -24,8 +24,8 @@
"url": "https://strapi.io"
}
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"scripts": {
"build": "yarn build:ts",
"build:ts": "tsc -p tsconfig.json",
@ -36,7 +36,7 @@
"watch": "yarn build:ts -w --preserveWatchOutput"
},
"directories": {
"lib": "./dist"
"lib": "./lib"
},
"dependencies": {
"@strapi/logger": "4.5.5",
@ -63,7 +63,7 @@
"@types/tar": "6.1.3",
"@types/tar-stream": "2.2.2",
"@types/uuid": "9.0.0",
"koa": "2.14.1",
"@types/koa": "2.13.1",
"rimraf": "3.0.2",
"typescript": "4.6.2"
},

View File

@ -102,6 +102,13 @@ export const destinationStages = [
'getSchemasStream',
];
/**
* Update the global store with the given strapi value
*/
export const setGlobalStrapi = (strapi: Strapi.Strapi): void => {
(global as unknown as Global).strapi = strapi;
};
/**
* Add jest expect helpers
*/

View File

@ -19,7 +19,7 @@ import type {
} from '../../types';
import type { Diff } from '../utils/json';
import compareSchemas from '../strategies';
import { compareSchemas } from './validation/schemas';
import { filter, map } from '../utils/stream';
export const TRANSFER_STAGES: ReadonlyArray<TransferStage> = Object.freeze([

View File

@ -0,0 +1 @@
export * as schemas from './schemas';

View File

@ -1,5 +1,5 @@
import type { Diff } from '../utils/json';
import * as utils from '../utils';
import type { Diff } from '../../../utils/json';
import * as utils from '../../../utils';
const strategies = {
// No diffs
@ -32,4 +32,4 @@ const compareSchemas = <T, P>(a: T, b: P, strategy: keyof typeof strategies) =>
return strategies[strategy](diffs);
};
export default compareSchemas;
export { compareSchemas };

View File

@ -0,0 +1 @@
export * as providers from './providers';

View File

@ -6,7 +6,7 @@ jest.mock('fs');
import fs from 'fs-extra';
import { Writable } from 'stream-chain';
import { createLocalFileDestinationProvider, ILocalFileDestinationProviderOptions } from '..';
import * as encryption from '../../../encryption/encrypt';
import * as encryption from '../../../../utils/encryption';
import { createFilePathFactory, createTarEntryStream } from '../utils';
fs.createWriteStream = jest.fn().mockReturnValue(
@ -18,14 +18,14 @@ fs.createWriteStream = jest.fn().mockReturnValue(
const filePath = './test-file';
jest.mock('../../../encryption/encrypt', () => {
jest.mock('../../../../utils/encryption', () => {
return {
__esModule: true,
createEncryptionCipher() {},
};
});
jest.mock('../../local-file-destination-provider/utils');
jest.mock('../utils');
describe('Local File Destination Provider', () => {
(createFilePathFactory as jest.Mock).mockImplementation(jest.fn());

View File

@ -6,7 +6,7 @@ import { stringer } from 'stream-json/jsonl/Stringer';
import { chain } from 'stream-chain';
import { Readable, Writable } from 'stream';
import { createEncryptionCipher } from '../../encryption/encrypt';
import { createEncryptionCipher } from '../../../utils/encryption';
import type {
IAsset,
IDestinationProvider,
@ -14,7 +14,7 @@ import type {
IMetadata,
ProviderType,
Stream,
} from '../../../types';
} from '../../../../types';
import { createFilePathFactory, createTarEntryStream } from './utils';
export interface ILocalFileDestinationProviderOptions {

View File

@ -0,0 +1,2 @@
export * from './source';
export * from './destination';

View File

@ -8,10 +8,9 @@ import { keyBy } from 'lodash/fp';
import { chain } from 'stream-chain';
import { pipeline, PassThrough } from 'stream';
import { parser } from 'stream-json/jsonl/Parser';
import type { IAsset, IMetadata, ISourceProvider, ProviderType } from '../../../types';
import type { IAsset, IMetadata, ISourceProvider, ProviderType } from '../../../../types';
import { createDecryptionCipher } from '../../encryption';
import * as utils from '../../utils';
import * as utils from '../../../utils';
type StreamItemArray = Parameters<typeof chain>[0];
@ -152,7 +151,7 @@ class LocalFileSourceProvider implements ISourceProvider {
}
if (encryption.enabled && encryption.key) {
streams.push(createDecryptionCipher(encryption.key));
streams.push(utils.encryption.createDecryptionCipher(encryption.key));
}
if (compression.enabled) {
@ -227,20 +226,22 @@ class LocalFileSourceProvider implements ISourceProvider {
return entryPath === filePath && entry.type === 'File';
},
/**
* Whenever an entry passes the filter method, process it
*/
async onentry(entry) {
// Collect all the content of the entry file
const content = await entry.collect();
// Parse from buffer to string to JSON
const parsedContent = JSON.parse(content.toString());
// Resolve the Promise with the parsed content
resolve(parsedContent);
try {
// Parse from buffer to string to JSON
const parsedContent = JSON.parse(content.toString());
// Cleanup (close the stream associated to the entry)
entry.destroy();
// Resolve the Promise with the parsed content
resolve(parsedContent);
} catch (e) {
reject(e);
} finally {
// Cleanup (close the stream associated to the entry)
entry.destroy();
}
},
}),
],

View File

@ -0,0 +1,4 @@
export * as engine from './engine';
export * as strapi from './strapi';
export * as file from './file';
export * as utils from './utils';

View File

@ -0,0 +1,36 @@
import { getStrapiFactory } from '../../__tests__/test-utils';
import { createTransferHandler } from '../remote/handlers';
import register from '../register';
afterEach(() => {
jest.clearAllMocks();
});
const strapiMockFactory = getStrapiFactory({
admin: {
routes: {
push: jest.fn(),
},
},
});
jest.mock('../remote/handlers', () => ({
createTransferHandler: jest.fn(),
}));
describe('Register the Transfer route', () => {
test('registers the /transfer route', () => {
const strapi = strapiMockFactory();
register(strapi);
expect(strapi.admin.routes.push).toHaveBeenCalledWith({
method: 'GET',
path: '/transfer',
handler: createTransferHandler(),
config: {
auth: false,
},
});
});
});

View File

@ -0,0 +1,5 @@
export * as providers from './providers';
export * as queries from './queries';
export * as remote from './remote';
export { default as register } from './register';

View File

@ -0,0 +1,6 @@
// Local
export * from './local-destination';
export * from './local-source';
// Remote
export * from './remote-destination';

View File

@ -1,8 +1,8 @@
import fse from 'fs-extra';
import { Writable, Readable } from 'stream';
import type { IAsset } from '../../../../types';
import type { IAsset } from '../../../../../types';
import { getStrapiFactory } from '../../../__tests__/test-utils';
import { getStrapiFactory } from '../../../../__tests__/test-utils';
import { createLocalStrapiDestinationProvider } from '../index';
const write = jest.fn((_chunk, _encoding, callback) => {

View File

@ -1,6 +1,10 @@
import { createLocalStrapiDestinationProvider } from '../index';
import * as restoreApi from '../strategies/restore';
import { getStrapiFactory, getContentTypes, setGlobalStrapi } from '../../test-utils';
import {
getStrapiFactory,
getContentTypes,
setGlobalStrapi,
} from '../../../../__tests__/test-utils';
afterEach(() => {
jest.clearAllMocks();

View File

@ -1,6 +1,10 @@
import { deleteRecords, restoreConfigs } from '../strategies/restore';
import { getStrapiFactory, getContentTypes, setGlobalStrapi } from '../../test-utils';
import { IConfiguration } from '../../../../types';
import {
getStrapiFactory,
getContentTypes,
setGlobalStrapi,
} from '../../../../__tests__/test-utils';
import { IConfiguration } from '../../../../../types';
const entities = [
{

View File

@ -1,10 +1,10 @@
import { Writable } from 'stream';
import path from 'path';
import * as fse from 'fs-extra';
import type { IAsset, IDestinationProvider, IMetadata, ProviderType } from '../../../types';
import type { IAsset, IDestinationProvider, IMetadata, ProviderType } from '../../../../types';
import { restore } from './strategies';
import * as utils from '../../utils';
import * as utils from '../../../utils';
export const VALID_CONFLICT_STRATEGIES = ['restore', 'merge'];
export const DEFAULT_CONFLICT_STRATEGY = 'restore';
@ -25,6 +25,9 @@ class LocalStrapiDestinationProvider implements IDestinationProvider {
strapi?: Strapi.Strapi;
/**
* The entities mapper is used to map old entities to their new IDs
*/
#entitiesMapper: { [type: string]: { [id: number]: number } };
constructor(options: ILocalStrapiDestinationProviderOptions) {
@ -123,6 +126,7 @@ class LocalStrapiDestinationProvider implements IDestinationProvider {
throw new Error(`Invalid strategy supplied: "${strategy}"`);
}
// TODO: Move this logic to the restore strategy
async getAssetsStream(): Promise<Writable> {
if (!this.strapi) {
throw new Error('Not able to stream Assets. Strapi instance not found');

View File

@ -1,6 +1,6 @@
import { Writable } from 'stream';
import chalk from 'chalk';
import { IConfiguration } from '../../../../../types';
import { IConfiguration } from '../../../../../../types';
const restoreCoreStore = async <T extends { value: unknown }>(strapi: Strapi.Strapi, data: T) => {
return strapi.db.query('strapi::core-store').create({

View File

@ -3,9 +3,9 @@ import type { SchemaUID } from '@strapi/strapi/lib/types/utils';
import { get } from 'lodash/fp';
import { Writable } from 'stream';
import type { IEntity } from '../../../../../types';
import { json } from '../../../../utils';
import * as shared from '../../../shared';
import type { IEntity } from '../../../../../../types';
import { json } from '../../../../../utils';
import * as queries from '../../../../queries';
interface IEntitiesRestoreStreamOptions {
strapi: Strapi.Strapi;
@ -14,7 +14,7 @@ interface IEntitiesRestoreStreamOptions {
const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
const { strapi, updateMappingTable } = options;
const query = shared.strapi.entity.createEntityQuery(strapi);
const query = queries.entity.createEntityQuery(strapi);
return new Writable({
objectMode: true,
@ -24,13 +24,19 @@ const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
const { create, getDeepPopulateComponentLikeQuery } = query(type);
const contentType = strapi.getModel(type);
const resolveType = (paths: string[]) => {
/**
* Resolve the component UID of an entity's attribute based
* on a given path (components & dynamic zones only)
*/
const resolveType = (paths: string[]): string | undefined => {
let cType = contentType;
let value: unknown = data;
for (const path of paths) {
value = get(path, value);
// Needed when the value of cType should be computed
// based on the next value (eg: dynamic zones)
if (typeof cType === 'function') {
cType = cType(value);
}
@ -48,24 +54,32 @@ const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
}
}
return cType.uid;
return cType?.uid;
};
try {
// Create the entity
const created = await create({
data,
populate: getDeepPopulateComponentLikeQuery(contentType, { select: 'id' }),
select: 'id',
});
// Compute differences between original & new entities
const diffs = json.diff(data, created);
updateMappingTable(type, id, created.id);
// For each difference found on an ID attribute,
// update the mapping the table accordingly
diffs.forEach((diff) => {
if (diff.kind === 'modified' && diff.path.at(-1) === 'id') {
const target = resolveType(diff.path);
// If no type is found for the given path, then ignore the diff
if (!target) {
return;
}
const [oldID, newID] = diff.values as [number, number];
updateMappingTable(target, oldID, newID);

View File

@ -1,5 +1,5 @@
import type { ContentTypeSchema } from '@strapi/strapi';
import * as shared from '../../../shared';
import * as queries from '../../../../queries';
export interface IRestoreOptions {
assets?: boolean;
@ -36,7 +36,7 @@ const deleteEntitiesRecord = async (
options: IRestoreOptions = {}
): Promise<IDeleteResults> => {
const { entities } = options;
const query = shared.strapi.entity.createEntityQuery(strapi);
const query = queries.entity.createEntityQuery(strapi);
const contentTypes = Object.values<ContentTypeSchema>(strapi.contentTypes);
const contentTypesToClear = contentTypes.filter((contentType) => {

View File

@ -1,6 +1,6 @@
import { Writable } from 'stream';
import { ILink } from '../../../../../types';
import { createLinkQuery } from '../../../shared/strapi/link';
import { ILink } from '../../../../../../types';
import { createLinkQuery } from '../../../../queries/link';
export const createLinksWriteStream = (
mapID: (uid: string, id: number) => number | undefined,

View File

@ -1,6 +1,10 @@
import { Readable } from 'stream';
import { collect, createMockedQueryBuilder, getStrapiFactory } from '../../../__tests__/test-utils';
import {
collect,
createMockedQueryBuilder,
getStrapiFactory,
} from '../../../../__tests__/test-utils';
import { createConfigurationStream } from '../configuration';
describe('Configuration', () => {

View File

@ -1,12 +1,12 @@
import { Readable, PassThrough } from 'stream';
import type { IEntity } from '../../../../types';
import type { IEntity } from '../../../../../types';
import {
collect,
getStrapiFactory,
getContentTypes,
createMockedQueryBuilder,
} from '../../../__tests__/test-utils';
} from '../../../../__tests__/test-utils';
import { createEntitiesStream, createEntitiesTransformStream } from '../entities';
describe('Local Strapi Source Provider - Entities Streaming', () => {

View File

@ -1,7 +1,11 @@
import { Readable } from 'stream';
import type { IEntity } from '../../../../types';
import type { IEntity } from '../../../../../types';
import { collect, createMockedQueryBuilder, getStrapiFactory } from '../../../__tests__/test-utils';
import {
collect,
createMockedQueryBuilder,
getStrapiFactory,
} from '../../../../__tests__/test-utils';
import { createLocalStrapiSourceProvider } from '..';
describe('Local Strapi Source Provider', () => {

View File

@ -1,7 +1,7 @@
import { Readable } from 'stream';
import { createLinksStream } from '../links';
import { collect, getStrapiFactory } from '../../../__tests__/test-utils';
import { collect, getStrapiFactory } from '../../../../__tests__/test-utils';
// TODO: entityService needs to be replaced with a mocked wrapper of db.connection and provide real metadata
describe.skip('Local Strapi Source Provider - Entities Streaming', () => {

View File

@ -2,7 +2,7 @@ import { join } from 'path';
import { readdir, stat, createReadStream } from 'fs-extra';
import { Duplex } from 'stream';
import type { IAsset } from '../../../types';
import type { IAsset } from '../../../../types';
const IGNORED_FILES = ['.gitkeep'];

View File

@ -2,7 +2,7 @@ import { chain } from 'stream-chain';
import { Readable } from 'stream';
import { set } from 'lodash/fp';
import type { IConfiguration } from '../../../types';
import type { IConfiguration } from '../../../../types';
/**
* Create a readable stream that export the Strapi app configuration

View File

@ -1,8 +1,8 @@
import type { ContentTypeSchema } from '@strapi/strapi';
import { Readable, PassThrough } from 'stream';
import * as shared from '../shared/strapi';
import { IEntity } from '../../../types';
import * as shared from '../../queries';
import { IEntity } from '../../../../types';
/**
* Generate and consume content-types streams in order to stream each entity individually

View File

@ -1,12 +1,12 @@
import { chain } from 'stream-chain';
import { Readable } from 'stream';
import type { IMetadata, ISourceProvider, ProviderType } from '../../../types';
import type { IMetadata, ISourceProvider, ProviderType } from '../../../../types';
import { createEntitiesStream, createEntitiesTransformStream } from './entities';
import { createLinksStream } from './links';
import { createConfigurationStream } from './configuration';
import { createAssetsStream } from './assets';
import * as utils from '../../utils';
import * as utils from '../../../utils';
export interface ILocalStrapiSourceProviderOptions {
getStrapi(): Strapi.Strapi | Promise<Strapi.Strapi>;

View File

@ -1,7 +1,7 @@
import { Readable } from 'stream';
import type { ILink } from '../../../types';
import { createLinkQuery } from '../shared/strapi/link';
import type { ILink } from '../../../../types';
import { createLinkQuery } from '../../queries/link';
/**
* Create a Readable which will stream all the links from a Strapi instance

View File

@ -11,8 +11,8 @@ import type {
IConfiguration,
TransferStage,
IAsset,
} from '../../../types';
import type { ILocalStrapiDestinationProviderOptions } from '../local-strapi-destination-provider';
} from '../../../../types';
import type { ILocalStrapiDestinationProviderOptions } from '../local-destination';
import { dispatch } from './utils';
interface ITokenAuth {

View File

@ -1,5 +1,6 @@
import { RelationAttribute } from '@strapi/strapi';
import { clone, isNil } from 'lodash/fp';
import { ILink } from '../../../../types';
import { ILink } from '../../../types';
// TODO: Remove any types when we'll have types for DB metadata

View File

@ -0,0 +1,12 @@
import { routes } from './remote';
/**
* This is intended to be called on Strapi register phase.
*
* It registers a transfer route in the Strapi admin router.
*/
const register = (strapi: Strapi.Strapi) => {
routes.registerAdminTransferRoute(strapi);
};
export default register;

View File

@ -0,0 +1 @@
export * from './push';

View File

@ -1,6 +1,6 @@
import { PassThrough, Writable } from 'stream-chain';
import { IAsset, IMetadata, PushTransferMessage, PushTransferStage } from '../../../types';
import { IAsset, IMetadata, PushTransferMessage, PushTransferStage } from '../../../../types';
import {
createLocalStrapiDestinationProvider,
ILocalStrapiDestinationProviderOptions,
@ -71,7 +71,7 @@ const createPushController = (options: ILocalStrapiDestinationProviderOptions):
streams.entities = provider.getEntitiesStream();
}
await writeAsync(streams.entities!, entity);
await writeAsync(streams.entities, entity);
},
async links(link) {
@ -79,7 +79,7 @@ const createPushController = (options: ILocalStrapiDestinationProviderOptions):
streams.links = await provider.getLinksStream();
}
await writeAsync(streams.links!, link);
await writeAsync(streams.links, link);
},
async configuration(config) {
@ -87,7 +87,7 @@ const createPushController = (options: ILocalStrapiDestinationProviderOptions):
streams.configuration = await provider.getConfigurationStream();
}
await writeAsync(streams.configuration!, config);
await writeAsync(streams.configuration, config);
},
async assets(payload) {

View File

@ -1,13 +1,14 @@
// eslint-disable-next-line node/no-extraneous-import
import type { Context } from 'koa';
import type { ServerOptions } from 'ws';
import { v4 } from 'uuid';
import { WebSocket } from 'ws';
import type { IPushController } from './push';
import type { IPushController } from './controllers/push';
import { InitMessage, Message, TransferKind } from '../../../types';
import createPushController from './push';
import createPushController from './controllers/push';
interface ITransferState {
kind?: TransferKind;
@ -15,7 +16,7 @@ interface ITransferState {
controller?: IPushController;
}
const createTransferController =
export const createTransferHandler =
(options: ServerOptions = {}) =>
async (ctx: Context) => {
const upgradeHeader = (ctx.request.headers.upgrade || '')
@ -143,5 +144,3 @@ const createTransferController =
ctx.respond = false;
}
};
export default createTransferController;

View File

@ -0,0 +1,2 @@
export * as controllers from './controllers';
export * as routes from './routes';

View File

@ -0,0 +1,35 @@
// eslint-disable-next-line node/no-extraneous-import
import type { Context } from 'koa';
import { createTransferHandler } from './handlers';
// Extend Strapi interface type to access the admin routes' API
// TODO: Remove this when the Strapi instances will be better typed
declare module '@strapi/strapi' {
interface Strapi {
admin: {
routes: {
method: string;
path: string;
handler: (ctx: Context) => Promise<void>;
config: unknown;
}[];
};
}
}
/**
* Register a transfer route in the Strapi admin router.
*
* It exposes a WS server that can be used to run and manage transfer processes.
*
* @param strapi - A Strapi instance
*/
export const registerAdminTransferRoute = (strapi: Strapi.Strapi) => {
strapi.admin.routes.push({
method: 'GET',
path: '/transfer',
handler: createTransferHandler(),
config: { auth: false },
});
};

View File

@ -1,5 +1,5 @@
import { Cipher, scryptSync, CipherKey, BinaryLike, createDecipheriv } from 'crypto';
import { EncryptionStrategy, Strategies, Algorithm } from '../../types';
import { EncryptionStrategy, Strategies, Algorithm } from '../../../types';
// different key values depending on algorithm chosen
const getDecryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => {
@ -33,6 +33,14 @@ const getDecryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => {
return strategies[algorithm];
};
/**
* It creates a cipher instance used for decryption
*
* @param key - The decryption key
* @param algorithm - The algorithm to use to create the Cipher
*
* @returns A {@link Cipher} instance created with the given key & algorithm
*/
export const createDecryptionCipher = (
key: string,
algorithm: Algorithm = 'aes-128-ecb'

View File

@ -1,5 +1,5 @@
import { createCipheriv, Cipher, scryptSync, CipherKey, BinaryLike } from 'crypto';
import { EncryptionStrategy, Strategies, Algorithm } from '../../types';
import { EncryptionStrategy, Strategies, Algorithm } from '../../../types';
// different key values depending on algorithm chosen
const getEncryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => {
@ -33,6 +33,14 @@ const getEncryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => {
return strategies[algorithm];
};
/**
* It creates a cipher instance used for encryption
*
* @param key - The encryption key
* @param algorithm - The algorithm to use to create the Cipher
*
* @returns A {@link Cipher} instance created with the given key & algorithm
*/
export const createEncryptionCipher = (
key: string,
algorithm: Algorithm = 'aes-128-ecb'

View File

@ -1,3 +1,4 @@
export * as encryption from './encryption';
export * as stream from './stream';
export * as json from './json';
export * as schema from './schema';

View File

@ -2,6 +2,13 @@ import { isArray, isObject, zip, isEqual, uniq } from 'lodash/fp';
const createContext = (): Context => ({ path: [] });
/**
* Compute differences between two JSON objects and returns them
*
* @param a - First object
* @param b - Second object
* @param ctx - Context used to keep track of the current path during recursion
*/
export const diff = (a: unknown, b: unknown, ctx: Context = createContext()): Diff[] => {
const diffs: Diff[] = [];
const { path } = ctx;
@ -70,7 +77,7 @@ export const diff = (a: unknown, b: unknown, ctx: Context = createContext()): Di
}
if (!isEqual(a, b)) {
modified();
return modified();
}
return diffs;

View File

@ -0,0 +1,27 @@
import type { Schema } from '@strapi/strapi';
import { mapValues, pick } from 'lodash/fp';
/**
* List of schema properties that should be kept when sanitizing schemas
*/
const VALID_SCHEMA_PROPERTIES = [
'collectionName',
'info',
'options',
'pluginOptions',
'attributes',
'kind',
'modelType',
'modelName',
'uid',
'plugin',
'globalId',
];
/**
* Sanitize a schemas dictionnary by omiting unwanted properties
* The list of allowed properties can be found here: {@link VALID_SCHEMA_PROPERTIES}
*/
export const mapSchemasValues = (schemas: Record<string, Schema>) => {
return mapValues(pick(VALID_SCHEMA_PROPERTIES), schemas);
};

View File

@ -0,0 +1,72 @@
import { Transform, Readable } from 'stream';
type TransformOptions = ConstructorParameters<typeof Transform>[0];
/**
* Create a filter stream that discard chunks which doesn't satisfies the given predicate
*
* @param predicate - A filter predicate, takes a stream data chunk as parameter and returns a boolean value
* @param options - Transform stream options
*/
export const filter = <T>(
predicate: (value: T) => boolean | Promise<boolean>,
options: TransformOptions = { objectMode: true }
): Transform => {
return new Transform({
...options,
async transform(chunk, _encoding, callback) {
const keep = await predicate(chunk);
callback(null, keep ? chunk : undefined);
},
});
};
/**
* Create a map stream that transform chunks using the given predicate
*
* @param predicate - A map predicate, takes a stream data chunk as parameter and returns a mapped value
* @param options - Transform stream options
*/
export const map = <T, U = T>(
predicate: (value: T) => U | Promise<U>,
options: TransformOptions = { objectMode: true }
): Transform => {
return new Transform({
...options,
async transform(chunk, _encoding, callback) {
const mappedValue = await predicate(chunk);
callback(null, mappedValue);
},
});
};
/**
* Collect every chunks from a Readable stream.
*
* @param stream - The redable stream to collect data from
* @param options.destroy - If set to true, it automatically calls `destroy()` on the given stream upon receiving the 'end' event
*/
export const collect = <T = unknown>(
stream: Readable,
options: { destroy: boolean } = { destroy: true }
): Promise<T[]> => {
const chunks: T[] = [];
return new Promise((resolve, reject) => {
stream
.on('close', () => resolve(chunks))
.on('error', reject)
.on('data', (chunk) => chunks.push(chunk))
.on('end', () => {
if (options.destroy) {
stream.destroy();
}
resolve(chunks);
});
});
};

View File

@ -4,11 +4,11 @@
"strict": true,
"lib": ["ESNEXT"],
"skipLibCheck": true,
"outDir": "dist",
"outDir": "lib",
"declaration": true,
"sourceMap": true,
"esModuleInterop": true
},
"include": ["types", "lib/**/*.ts"],
"exclude": ["node_modules", "lib/**/__tests__"]
"include": ["types", "src/**/*.ts"],
"exclude": ["node_modules", "src/**/__tests__"]
}

View File

@ -1,4 +1,4 @@
import type { ILocalStrapiDestinationProviderOptions } from '../lib';
import type { ILocalStrapiDestinationProviderOptions } from '../src/strapi/providers';
import type { IAsset, IConfiguration, IEntity, ILink } from './common-entities';
/**

View File

@ -1,33 +1,39 @@
'use strict';
describe('export', () => {
describe('Export', () => {
const defaultFileName = 'defaultFilename';
// mock @strapi/data-transfer
const mockDataTransfer = {
createLocalFileDestinationProvider: jest.fn().mockReturnValue({ name: 'testDest' }),
createLocalStrapiSourceProvider: jest.fn().mockReturnValue({ name: 'testSource' }),
createTransferEngine() {
return {
transfer: jest.fn().mockReturnValue(Promise.resolve({})),
progress: {
on: jest.fn(),
stream: {
file: {
providers: {
createLocalFileDestinationProvider: jest.fn().mockReturnValue({ name: 'testDest' }),
},
},
strapi: {
providers: {
createLocalStrapiSourceProvider: jest.fn().mockReturnValue({ name: 'testSource' }),
},
},
engine: {
createTransferEngine() {
return {
transfer: jest.fn().mockReturnValue(Promise.resolve({})),
progress: {
on: jest.fn(),
stream: {
on: jest.fn(),
},
},
},
sourceProvider: { name: 'testSource' },
destinationProvider: { name: 'testDestination' },
};
sourceProvider: { name: 'testSource' },
destinationProvider: { name: 'testDestination' },
};
},
},
};
jest.mock(
'@strapi/data-transfer',
() => {
return mockDataTransfer;
},
{ virtual: true }
);
jest.mock('@strapi/data-transfer/lib/engine', () => mockDataTransfer.engine, { virtual: true });
jest.mock('@strapi/data-transfer/lib/strapi', () => mockDataTransfer.strapi, { virtual: true });
jest.mock('@strapi/data-transfer/lib/file', () => mockDataTransfer.file, { virtual: true });
// mock utils
const mockUtils = {
@ -76,7 +82,7 @@ describe('export', () => {
await exportCommand({ file: filename });
});
expect(mockDataTransfer.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
file: { path: filename },
})
@ -90,7 +96,7 @@ describe('export', () => {
});
expect(mockUtils.getDefaultExportName).toHaveBeenCalledTimes(1);
expect(mockDataTransfer.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
file: { path: defaultFileName },
})
@ -103,7 +109,7 @@ describe('export', () => {
await exportCommand({ encrypt });
});
expect(mockDataTransfer.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
encryption: { enabled: encrypt },
})
@ -117,7 +123,7 @@ describe('export', () => {
await exportCommand({ encrypt, key });
});
expect(mockDataTransfer.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
encryption: { enabled: encrypt, key },
})
@ -129,7 +135,7 @@ describe('export', () => {
await exportCommand({ compress: false });
});
expect(mockDataTransfer.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
compression: { enabled: false },
})
@ -137,7 +143,7 @@ describe('export', () => {
await expectExit(1, async () => {
await exportCommand({ compress: true });
});
expect(mockDataTransfer.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith(
expect.objectContaining({
compression: { enabled: true },
})

View File

@ -3,20 +3,21 @@
const utils = require('../../transfer/utils');
const mockDataTransfer = {
createRemoteStrapiDestinationProvider: jest.fn(),
createLocalStrapiSourceProvider: jest.fn(),
createTransferEngine: jest.fn().mockReturnValue({
transfer: jest.fn().mockReturnValue(Promise.resolve({})),
}),
strapi: {
providers: {
createRemoteStrapiDestinationProvider: jest.fn(),
createLocalStrapiSourceProvider: jest.fn(),
},
},
engine: {
createTransferEngine: jest.fn().mockReturnValue({
transfer: jest.fn().mockReturnValue(Promise.resolve({})),
}),
},
};
jest.mock(
'@strapi/data-transfer',
() => {
return mockDataTransfer;
},
{ virtual: true }
);
jest.mock('@strapi/data-transfer/lib/engine', () => mockDataTransfer.engine, { virtual: true });
jest.mock('@strapi/data-transfer/lib/strapi', () => mockDataTransfer.strapi, { virtual: true });
const expectExit = async (code, fn) => {
const exit = jest.spyOn(process, 'exit').mockImplementation((number) => {
@ -37,7 +38,7 @@ jest.mock('../../transfer/utils');
const destinationUrl = 'ws://strapi.com';
describe('transfer', () => {
describe('Transfer', () => {
beforeEach(() => {
jest.resetAllMocks();
});
@ -47,7 +48,9 @@ describe('transfer', () => {
await transferCommand({ from: 'local', to: destinationUrl });
});
expect(mockDataTransfer.createRemoteStrapiDestinationProvider).toHaveBeenCalledWith(
expect(
mockDataTransfer.strapi.providers.createRemoteStrapiDestinationProvider
).toHaveBeenCalledWith(
expect.objectContaining({
url: destinationUrl,
})
@ -61,7 +64,9 @@ describe('transfer', () => {
await transferCommand({ from: 'local', to: destinationUrl });
});
expect(mockDataTransfer.createRemoteStrapiDestinationProvider).toHaveBeenCalledWith(
expect(
mockDataTransfer.strapi.providers.createRemoteStrapiDestinationProvider
).toHaveBeenCalledWith(
expect.objectContaining({
strategy: 'restore',
})
@ -72,7 +77,9 @@ describe('transfer', () => {
await transferCommand({ from: 'local', to: destinationUrl });
});
expect(mockDataTransfer.createRemoteStrapiDestinationProvider).toHaveBeenCalledWith(
expect(
mockDataTransfer.strapi.providers.createRemoteStrapiDestinationProvider
).toHaveBeenCalledWith(
expect.objectContaining({
url: destinationUrl,
})
@ -84,7 +91,9 @@ describe('transfer', () => {
await transferCommand({ from: 'local', to: destinationUrl });
});
expect(mockDataTransfer.createRemoteStrapiDestinationProvider).toHaveBeenCalledWith(
expect(
mockDataTransfer.strapi.providers.createRemoteStrapiDestinationProvider
).toHaveBeenCalledWith(
expect.objectContaining({
strategy: 'restore',
})
@ -96,7 +105,7 @@ describe('transfer', () => {
await transferCommand({ from: 'local', to: destinationUrl });
});
expect(mockDataTransfer.createLocalStrapiSourceProvider).toHaveBeenCalled();
expect(mockDataTransfer.strapi.providers.createLocalStrapiSourceProvider).toHaveBeenCalled();
expect(utils.createStrapiInstance).toHaveBeenCalled();
});

View File

@ -1,10 +1,12 @@
'use strict';
const {
createLocalFileDestinationProvider,
createLocalStrapiSourceProvider,
createTransferEngine,
} = require('@strapi/data-transfer');
providers: { createLocalFileDestinationProvider },
} = require('@strapi/data-transfer/lib/file');
const {
providers: { createLocalStrapiSourceProvider },
} = require('@strapi/data-transfer/lib/strapi');
const { createTransferEngine } = require('@strapi/data-transfer/lib/engine');
const { isObject, isString, isFinite, toNumber } = require('lodash/fp');
const fs = require('fs-extra');
const chalk = require('chalk');

View File

@ -1,13 +1,17 @@
'use strict';
const {
createLocalFileSourceProvider,
createLocalStrapiDestinationProvider,
providers: { createLocalFileSourceProvider },
} = require('@strapi/data-transfer/lib/file');
const {
providers: { createLocalStrapiDestinationProvider, DEFAULT_CONFLICT_STRATEGY },
} = require('@strapi/data-transfer/lib/strapi');
const {
createTransferEngine,
DEFAULT_VERSION_STRATEGY,
DEFAULT_SCHEMA_STRATEGY,
DEFAULT_CONFLICT_STRATEGY,
} = require('@strapi/data-transfer');
} = require('@strapi/data-transfer/lib/engine');
const { isObject } = require('lodash/fp');
const path = require('path');
@ -43,6 +47,7 @@ module.exports = async (opts) => {
async getStrapi() {
return strapiInstance;
},
autoDestroy: false,
strategy: opts.conflictStrategy || DEFAULT_CONFLICT_STRATEGY,
restore: {
entities: { exclude: DEFAULT_IGNORED_CONTENT_TYPES },
@ -107,7 +112,9 @@ module.exports = async (opts) => {
}
// Note: Telemetry can't be sent in a finish event, because it runs async after this block but we can't await it, so if process.exit is used it won't send
await strapi.telemetry.send('didDEITSProcessFinish', getTelemetryPayload());
await strapiInstance.telemetry.send('didDEITSProcessFinish', getTelemetryPayload());
await strapiInstance.destroy();
process.exit(0);
};

View File

@ -1,12 +1,9 @@
'use strict';
const { createTransferEngine } = require('@strapi/data-transfer/lib/engine');
const {
createRemoteStrapiDestinationProvider,
createLocalStrapiSourceProvider,
createTransferEngine,
// TODO: we need to solve this issue with typescript modules
// eslint-disable-next-line import/no-unresolved, node/no-missing-require
} = require('@strapi/data-transfer');
providers: { createRemoteStrapiDestinationProvider, createLocalStrapiSourceProvider },
} = require('@strapi/data-transfer/lib/strapi');
const { isObject } = require('lodash/fp');
const chalk = require('chalk');

View File

@ -6353,6 +6353,20 @@
"@types/koa-compose" "*"
"@types/node" "*"
"@types/koa@2.13.1":
version "2.13.1"
resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.1.tgz#e29877a6b5ad3744ab1024f6ec75b8cbf6ec45db"
integrity sha512-Qbno7FWom9nNqu0yHZ6A0+RWt4mrYBhw3wpBAQ3+IuzGcLlfeYkzZrnMq5wsxulN2np8M4KKeUpTodsOsSad5Q==
dependencies:
"@types/accepts" "*"
"@types/content-disposition" "*"
"@types/cookies" "*"
"@types/http-assert" "*"
"@types/http-errors" "*"
"@types/keygrip" "*"
"@types/koa-compose" "*"
"@types/node" "*"
"@types/koa__cors@^3.0.1":
version "3.3.0"
resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.3.0.tgz#2986b320d3d7ddf05c4e2e472b25a321cb16bd3b"
@ -15530,35 +15544,6 @@ koa@2.13.4, koa@^2.13.4:
type-is "^1.6.16"
vary "^1.1.2"
koa@2.14.1:
version "2.14.1"
resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.1.tgz#defb9589297d8eb1859936e777f3feecfc26925c"
integrity sha512-USJFyZgi2l0wDgqkfD27gL4YGno7TfUkcmOe6UOLFOVuN+J7FwnNu4Dydl4CUQzraM1lBAiGed0M9OVJoT0Kqw==
dependencies:
accepts "^1.3.5"
cache-content-type "^1.0.0"
content-disposition "~0.5.2"
content-type "^1.0.4"
cookies "~0.8.0"
debug "^4.3.2"
delegates "^1.0.0"
depd "^2.0.0"
destroy "^1.0.4"
encodeurl "^1.0.2"
escape-html "^1.0.3"
fresh "~0.5.2"
http-assert "^1.3.0"
http-errors "^1.6.3"
is-generator-function "^1.0.7"
koa-compose "^4.1.0"
koa-convert "^2.0.0"
on-finished "^2.3.0"
only "~0.0.2"
parseurl "^1.3.2"
statuses "^1.5.0"
type-is "^1.6.16"
vary "^1.1.2"
kuler@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
@ -18296,6 +18281,8 @@ path-case@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/path-case/-/path-case-2.1.1.tgz#94b8037c372d3fe2906e465bb45e25d226e8eea5"
integrity sha1-lLgDfDctP+KQbkZbtF4l0ibo7qU=
dependencies:
no-case "^2.2.0"
path-dirname@^1.0.0:
version "1.0.2"