diff --git a/packages/database/src/__tests__/database.test.ts b/packages/database/src/__tests__/database.test.ts index 28473393b..2c8d3e5de 100644 --- a/packages/database/src/__tests__/database.test.ts +++ b/packages/database/src/__tests__/database.test.ts @@ -1,8 +1,25 @@ -import { mockDatabase } from './index'; import path from 'path'; import { Model } from '..'; +import { mockDatabase } from './index'; describe('database', () => { + test('close state', async () => { + const db = mockDatabase(); + expect(db.closed()).toBeFalsy(); + await db.close(); + expect(db.closed()).toBeTruthy(); + await db.reconnect(); + expect(db.closed()).toBeFalsy(); + }); + + test('reconnect', async () => { + const db = mockDatabase(); + await db.sequelize.authenticate(); + await db.close(); + await db.reconnect(); + await db.sequelize.authenticate(); + }); + test('get repository', async () => { const db = mockDatabase(); db.collection({ diff --git a/packages/database/src/database.ts b/packages/database/src/database.ts index 9efee278e..b7e613d03 100644 --- a/packages/database/src/database.ts +++ b/packages/database/src/database.ts @@ -1,18 +1,15 @@ -import { Sequelize, ModelCtor, Model, Options, SyncOptions, Op, Utils } from 'sequelize'; - -import { EventEmitter } from 'events'; -import { Collection, CollectionOptions, RepositoryType } from './collection'; -import * as FieldTypes from './fields'; -import { BaseFieldOptions, Field, FieldContext, FieldOptions, RelationField } from './fields'; import { applyMixins, AsyncEmitter } from '@nocobase/utils'; - import merge from 'deepmerge'; -import { ModelHook } from './model-hook'; +import { EventEmitter } from 'events'; +import { Model, ModelCtor, Op, Options, QueryInterfaceDropAllTablesOptions, Sequelize, SyncOptions, Utils } from 'sequelize'; +import { Collection, CollectionOptions, RepositoryType } from './collection'; import { ImporterReader, ImportFileExtension } from './collection-importer'; - +import * as FieldTypes from './fields'; +import { Field, FieldContext, RelationField } from './fields'; +import { ModelHook } from './model-hook'; import extendOperators from './operators'; -import { Repository } from './repository'; import { RelationRepository } from './relation-repository/relation-repository'; +import { Repository } from './repository'; export interface MergeOptions extends merge.Options {} @@ -33,6 +30,10 @@ interface RegisterOperatorsContext { field?: Field; } +export interface CleanOptions extends QueryInterfaceDropAllTablesOptions { + drop?: boolean; +} + type OperatorFunc = (value: any, ctx?: RegisterOperatorsContext) => any; export class Database extends EventEmitter implements AsyncEmitter { @@ -219,6 +220,29 @@ export class Database extends EventEmitter implements AsyncEmitter { return result; } + async clean(options: CleanOptions) { + const { drop, ...others } = options; + if (drop) { + await this.sequelize.getQueryInterface().dropAllTables(others); + } + } + + async reconnect() { + // @ts-ignore + const ConnectionManager = this.sequelize.dialect.connectionManager.constructor; + // @ts-ignore + const connectionManager = new ConnectionManager(this.sequelize.dialect, this.sequelize); + // @ts-ignore + this.sequelize.dialect.connectionManager = connectionManager; + // @ts-ignore + this.sequelize.connectionManager = connectionManager; + } + + closed() { + // @ts-ignore + return this.sequelize.connectionManager.pool._draining; + } + async close() { return this.sequelize.close(); } diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index fd147082f..d028c771d 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,14 +1,15 @@ -export * from './database'; +export { Model, ModelCtor, SyncOptions } from 'sequelize'; export * from './collection'; -export * from './repository'; +export * from './database'; export { Database as default } from './database'; +export * from './fields'; +export * from './magic-attribute-model'; export * from './relation-repository/belongs-to-many-repository'; export * from './relation-repository/belongs-to-repository'; export * from './relation-repository/hasmany-repository'; -export * from './relation-repository/single-relation-repository'; export * from './relation-repository/multiple-relation-repository'; - -export { Model, ModelCtor } from 'sequelize'; -export * from './fields'; +export * from './relation-repository/single-relation-repository'; +export * from './repository'; export * from './update-associations'; -export * from './magic-attribute-model'; + + diff --git a/packages/plugin-acl/src/__tests__/prepare.ts b/packages/plugin-acl/src/__tests__/prepare.ts index c1501c113..1822dbdaa 100644 --- a/packages/plugin-acl/src/__tests__/prepare.ts +++ b/packages/plugin-acl/src/__tests__/prepare.ts @@ -19,7 +19,6 @@ export async function prepareApp() { registerActions: true, }); - await app.cleanDb(); app.plugin(PluginUiSchema); app.plugin(PluginCollectionManager); @@ -30,7 +29,7 @@ export async function prepareApp() { }); app.plugin(PluginACL); - await app.loadAndSync(); + await app.loadAndInstall(); return app; } diff --git a/packages/server/src/__tests__/application.test.ts b/packages/server/src/__tests__/application.test.ts index 11a7405f8..ff8656675 100644 --- a/packages/server/src/__tests__/application.test.ts +++ b/packages/server/src/__tests__/application.test.ts @@ -2,7 +2,9 @@ import supertest from 'supertest'; import { Application } from '../application'; import { Plugin } from '../plugin'; -class MyPlugin extends Plugin {} +class MyPlugin extends Plugin { + load() {} +} describe('application', () => { let app: Application; diff --git a/packages/server/src/__tests__/life-cycle.test.ts b/packages/server/src/__tests__/life-cycle.test.ts new file mode 100644 index 000000000..f228e3536 --- /dev/null +++ b/packages/server/src/__tests__/life-cycle.test.ts @@ -0,0 +1,53 @@ +import Application from '../application'; +import { Plugin } from '../plugin'; + +describe('application life cycle', () => { + it('should start application', async () => { + const app = new Application({ + database: { + dialect: 'sqlite', + storage: ':memory:', + }, + }); + + const loadFn = jest.fn(); + const installFn = jest.fn(); + + // register plugin + class TestPlugin extends Plugin { + beforeLoad() {} + + getName() { + return 'Test'; + } + + load() { + loadFn(); + this.app.on('beforeInstall', () => { + installFn(); + }); + } + } + app.plugin(TestPlugin); + await app.load(); + expect(loadFn).toHaveBeenCalledTimes(1); + expect(installFn).toHaveBeenCalledTimes(0); + await app.install(); + expect(installFn).toHaveBeenCalledTimes(1); + }); + + it('should listen application', async () => { + const app = new Application({ + database: { + dialect: 'sqlite', + storage: ':memory:', + }, + }); + + await app.start({ listen: { port: 13090 } }); + expect(app.listenServer).not.toBeNull(); + + await app.stop(); + expect(app.listenServer).toBeNull(); + }); +}); diff --git a/packages/server/src/__tests__/plugin.test.ts b/packages/server/src/__tests__/plugin.test.ts index 4cbef7fa2..14a5b7b0c 100644 --- a/packages/server/src/__tests__/plugin.test.ts +++ b/packages/server/src/__tests__/plugin.test.ts @@ -1,7 +1,7 @@ -import supertest from 'supertest'; import { Application } from '../application'; import { Plugin } from '../plugin'; -import path from 'path'; +import Plugin1 from './plugins/plugin1'; +import Plugin2 from './plugins/plugin2'; import Plugin3 from './plugins/plugin3'; describe('plugin', () => { @@ -29,101 +29,55 @@ describe('plugin', () => { }); afterEach(async () => { - return app.db.close(); + await app.destroy(); }); describe('define', () => { - it('plugin name', async () => { - const plugin = app.plugin(function abc() {}); - expect(plugin).toBeInstanceOf(Plugin); - expect(plugin.getName()).toBe('abc'); - }); + it('should add plugin with options', async () => { + class MyPlugin extends Plugin { + load() {} + } - it('plugin name', async () => { - const plugin = app.plugin({ - name: 'plugin-name2', - async load() {}, + const plugin = app.plugin(MyPlugin, { + test: 'hello', }); - expect(plugin).toBeInstanceOf(Plugin); - expect(plugin.getName()).toBe('plugin-name2'); + + expect(plugin.options['test']).toEqual('hello'); }); it('plugin name', async () => { - const plugin = app.plugin({ - name: 'plugin-name3', - load: function () {}, + interface Options { + a?: string; + } + class MyPlugin extends Plugin { + load() { + this.options.a; + } + } + const plugin = app.plugin(MyPlugin, { + a: 'aa', }); - expect(plugin).toBeInstanceOf(Plugin); - expect(plugin.getName()).toBe('plugin-name3'); - }); - - it('plugin name', async () => { - class MyPlugin extends Plugin {} - const plugin = app.plugin(MyPlugin); + plugin.setOptions({ + a: 'a' + }) expect(plugin).toBeInstanceOf(MyPlugin); expect(plugin.getName()).toBe('MyPlugin'); }); it('plugin name', async () => { - class MyPlugin extends Plugin {} - const plugin = app.plugin({ - plugin: MyPlugin, - }); - expect(plugin).toBeInstanceOf(MyPlugin); - expect(plugin.getName()).toBe('MyPlugin'); - }); - - it('plugin name', async () => { - const plugin = app.plugin(path.resolve(__dirname, './plugins/plugin1')); + const plugin = app.plugin(Plugin1); expect(plugin).toBeInstanceOf(Plugin); - expect(plugin.getName()).toBe('abc'); + expect(plugin.getName()).toBe('Plugin1'); }); it('plugin name', async () => { - const plugin = app.plugin(path.resolve(__dirname, './plugins/plugin3')); + const plugin = app.plugin(Plugin3); expect(plugin).toBeInstanceOf(Plugin3); expect(plugin.getName()).toBe('Plugin3'); }); }); describe('load', () => { - it('plugin load', async () => { - app.plugin(function abc(this: Plugin) { - this.app.collection({ - name: 'tests', - }); - }); - await app.load(); - const Test = app.db.getCollection('tests'); - expect(Test).toBeDefined(); - }); - - it('plugin load', async () => { - app.plugin({ - load() { - app.collection({ - name: 'tests', - }); - }, - }); - await app.load(); - const Test = app.db.getCollection('tests'); - expect(Test).toBeDefined(); - }); - - it('plugin load', async () => { - app.plugin({ - load: () => { - app.collection({ - name: 'tests', - }); - }, - }); - await app.load(); - const Test = app.db.getCollection('tests'); - expect(Test).toBeDefined(); - }); - it('plugin load', async () => { app.plugin( class MyPlugin extends Plugin { @@ -140,21 +94,21 @@ describe('plugin', () => { }); it('plugin load', async () => { - app.plugin(path.resolve(__dirname, './plugins/plugin1')); + app.plugin(Plugin1); await app.load(); const Test = app.db.getCollection('tests'); expect(Test).toBeDefined(); }); it('plugin load', async () => { - app.plugin(path.resolve(__dirname, './plugins/plugin2')); + app.plugin(Plugin2); await app.load(); const Test = app.db.getCollection('tests'); expect(Test).toBeDefined(); }); it('plugin load', async () => { - app.plugin(path.resolve(__dirname, './plugins/plugin3')); + app.plugin(Plugin3); await app.load(); const Test = app.db.getCollection('tests'); expect(Test).toBeDefined(); diff --git a/packages/server/src/__tests__/plugins/plugin1.ts b/packages/server/src/__tests__/plugins/plugin1.ts index 805e06e4a..28af21064 100644 --- a/packages/server/src/__tests__/plugins/plugin1.ts +++ b/packages/server/src/__tests__/plugins/plugin1.ts @@ -1,7 +1,9 @@ import { Plugin } from '../../plugin'; -export default function abc(this: Plugin) { - this.app.collection({ - name: 'tests', - }); +export default class Plugin1 extends Plugin { + async load() { + this.app.collection({ + name: 'tests', + }); + } } diff --git a/packages/server/src/__tests__/plugins/plugin2.ts b/packages/server/src/__tests__/plugins/plugin2.ts index e24dd7a29..d9f0b6730 100644 --- a/packages/server/src/__tests__/plugins/plugin2.ts +++ b/packages/server/src/__tests__/plugins/plugin2.ts @@ -1,9 +1,9 @@ -import { IPlugin } from '../../plugin'; +import { Plugin } from '../../plugin'; -export default { - load() { +export default class Plugin2 extends Plugin { + async load() { this.app.collection({ name: 'tests', }); - }, -} as IPlugin; + } +} diff --git a/packages/server/src/application.ts b/packages/server/src/application.ts index 3d79dea9e..d8ccdfa3b 100644 --- a/packages/server/src/application.ts +++ b/packages/server/src/application.ts @@ -1,11 +1,15 @@ -import Koa from 'koa'; -import { Command, CommandOptions } from 'commander'; -import Database, { DatabaseOptions, CollectionOptions } from '@nocobase/database'; -import Resourcer, { ResourceOptions } from '@nocobase/resourcer'; -import { PluginType, Plugin, PluginOptions } from './plugin'; import { registerActions } from '@nocobase/actions'; -import { createCli, createI18n, createDatabase, createResourcer, registerMiddlewares } from './helper'; +import Database, { CleanOptions, CollectionOptions, DatabaseOptions, SyncOptions } from '@nocobase/database'; +import Resourcer, { ResourceOptions } from '@nocobase/resourcer'; +import { applyMixins, AsyncEmitter } from '@nocobase/utils'; +import { Command, CommandOptions } from 'commander'; +import { Server } from 'http'; import { i18n, InitOptions } from 'i18next'; +import Koa from 'koa'; +import { isBoolean } from 'lodash'; +import { createCli, createDatabase, createI18n, createResourcer, registerMiddlewares } from './helper'; +import { Plugin } from './plugin'; +import { PluginManager } from './plugin-manager'; export interface ResourcerOptions { prefix?: string; @@ -45,7 +49,31 @@ interface ActionsOptions { resourceNames?: string[]; } -export class Application extends Koa { +interface ListenOptions { + port?: number | undefined; + host?: string | undefined; + backlog?: number | undefined; + path?: string | undefined; + exclusive?: boolean | undefined; + readableAll?: boolean | undefined; + writableAll?: boolean | undefined; + /** + * @default false + */ + ipv6Only?: boolean | undefined; + signal?: AbortSignal | undefined; +} + +interface StartOptions { + listen?: ListenOptions; +} + +interface InstallOptions { + clean?: CleanOptions | boolean; + sync?: SyncOptions; +} + +export class Application extends Koa implements AsyncEmitter { public readonly db: Database; public readonly resourcer: Resourcer; @@ -54,8 +82,12 @@ export class Application exten public readonly i18n: i18n; + public readonly pm: PluginManager; + protected plugins = new Map(); + public listenServer: Server; + constructor(options: ApplicationOptions) { super(); @@ -64,12 +96,20 @@ export class Application exten this.cli = createCli(this, options); this.i18n = createI18n(options); + this.pm = new PluginManager({ + app: this, + }); + registerMiddlewares(this, options); if (options.registerActions !== false) { registerActions(this); } } + plugin(pluginClass: any, options?: O): Plugin { + return this.pm.add(pluginClass, options); + } + use( middleware: Koa.Middleware, options?: MiddlewareOptions, @@ -98,110 +138,12 @@ export class Application exten return (this.cli as any)._findCommand(name); } - plugin(options?: PluginType | PluginOptions, ext?: PluginOptions): Plugin { - if (typeof options === 'string') { - return this.plugin(require(options).default, ext); - } - let instance: Plugin; - if (typeof options === 'function') { - try { - // @ts-ignore - instance = new options({ - name: options.name, - ...ext, - app: this, - }); - if (!(instance instanceof Plugin)) { - throw new Error('plugin must be instanceof Plugin'); - } - } catch (err) { - // console.log(err); - instance = new Plugin({ - name: options.name, - ...ext, - // @ts-ignore - load: options, - app: this, - }); - } - } else if (typeof options === 'object') { - const plugin = options.plugin || Plugin; - instance = new plugin({ - name: options.plugin ? plugin.name : undefined, - ...options, - ...ext, - app: this, - }); - } - const name = instance.getName(); - if (this.plugins.has(name)) { - throw new Error(`plugin name [${name}] is repeated`); - } - this.plugins.set(name, instance); - return instance; - } - async load() { - await this.emitAsync('plugins.beforeLoad'); - for (const [name, plugin] of this.plugins) { - await this.emitAsync(`plugins.${name}.beforeLoad`); - await plugin.load(); - await this.emitAsync(`plugins.${name}.afterLoad`); - } - await this.emitAsync('plugins.afterLoad'); + await this.pm.load(); } getPlugin

(name: string) { - return this.plugins.get(name) as P; - } - - async emitAsync(event: string | symbol, ...args: any[]): Promise { - // @ts-ignore - const events = this._events; - let callbacks = events[event]; - if (!callbacks) { - return false; - } - // helper function to reuse as much code as possible - const run = (cb) => { - switch (args.length) { - // fast cases - case 0: - cb = cb.call(this); - break; - case 1: - cb = cb.call(this, args[0]); - break; - case 2: - cb = cb.call(this, args[0], args[1]); - break; - case 3: - cb = cb.call(this, args[0], args[1], args[2]); - break; - // slower - default: - cb = cb.apply(this, args); - } - - if (cb && (cb instanceof Promise || typeof cb.then === 'function')) { - return cb; - } - - return Promise.resolve(true); - }; - - if (typeof callbacks === 'function') { - await run(callbacks); - } else if (typeof callbacks === 'object') { - callbacks = callbacks.slice().filter(Boolean); - await callbacks.reduce((prev, next) => { - return prev.then((res) => { - return run(next).then((result) => Promise.resolve(res.concat(result))); - }); - }, Promise.resolve([])); - } - - return true; + return this.pm.get(name) as P; } async parse(argv = process.argv) { @@ -209,9 +151,73 @@ export class Application exten return this.cli.parseAsync(argv); } - async destroy() { - await this.db.close(); + async start(options?: StartOptions) { + // reconnect database + if (this.db.closed()) { + await this.db.reconnect(); + } + + await this.emitAsync('beforeStart', this, options); + + if (options?.listen?.port) { + const listen = () => + new Promise((resolve) => { + const Server = this.listen(options?.listen, () => { + resolve(Server); + }); + }); + + // @ts-ignore + this.listenServer = await listen(); + } + + await this.emitAsync('afterStart', this, options); } + + async stop() { + await this.emitAsync('beforeStop', this); + + // close database connection + await this.db.close(); + + // close http server + if (this.listenServer) { + const closeServer = () => + new Promise((resolve, reject) => { + this.listenServer.close((err) => { + if (err) { + return reject(err); + } + + this.listenServer = null; + resolve(true); + }); + }); + + await closeServer(); + } + + await this.emitAsync('afterStop', this); + } + + async destroy() { + await this.emitAsync('beforeDestroy', this); + await this.stop(); + await this.emitAsync('afterDestroy', this); + } + + async install(options?: InstallOptions) { + if (options?.clean) { + await this.db.clean(isBoolean(options.clean) ? { drop: options.clean } : options.clean); + } + await this.db.sync(options?.sync); + await this.emitAsync('beforeInstall', this, options); + await this.emitAsync('afterInstall', this, options); + } + + emitAsync: (event: string | symbol, ...args: any[]) => Promise; } +applyMixins(Application, [AsyncEmitter]); + export default Application; diff --git a/packages/server/src/helper.ts b/packages/server/src/helper.ts index 19dd22c8a..2aa4acf12 100644 --- a/packages/server/src/helper.ts +++ b/packages/server/src/helper.ts @@ -86,7 +86,7 @@ export function createCli(app: Application, options: ApplicationOptions) { const cli = args.pop(); const opts = cli.opts(); await app.emitAsync('beforeStart'); - app.listen(opts.port || 3000); + await app.start(opts.port || 3000); console.log(`http://localhost:${opts.port || 3000}/`); }); return cli; diff --git a/packages/server/src/plugin-manager.ts b/packages/server/src/plugin-manager.ts new file mode 100644 index 000000000..1c12f9ef1 --- /dev/null +++ b/packages/server/src/plugin-manager.ts @@ -0,0 +1,49 @@ +import Application from './application'; +import { Plugin } from './plugin'; + +interface PluginManagerOptions { + app: Application; +} + +export class PluginManager { + app: Application; + protected plugins = new Map(); + + constructor(options: PluginManagerOptions) { + this.app = options.app; + } + + get(name: string) { + return this.plugins.get(name); + } + + add

(pluginClass: any, options?: O): P { + const instance = new pluginClass(this.app, options); + + const name = instance.getName(); + + if (this.plugins.has(name)) { + throw new Error(`plugin name [${name}] `); + } + + this.plugins.set(name, instance); + + return instance; + } + + async load() { + await this.app.emitAsync('beforeLoadAll'); + + for (const [name, plugin] of this.plugins) { + await plugin.beforeLoad(); + } + + for (const [name, plugin] of this.plugins) { + await this.app.emitAsync('beforeLoadPlugin', plugin); + await plugin.load(); + await this.app.emitAsync('afterLoadPlugin', plugin); + } + + await this.app.emitAsync('afterLoadAll'); + } +} diff --git a/packages/server/src/plugin.ts b/packages/server/src/plugin.ts index 9ef9a3066..37429fdb9 100644 --- a/packages/server/src/plugin.ts +++ b/packages/server/src/plugin.ts @@ -1,14 +1,13 @@ -import { uid } from '@nocobase/utils'; +import { Database } from '@nocobase/database'; import { Application } from './application'; -import _ from 'lodash'; -export interface IPlugin { - install?: (this: Plugin) => void; - load?: (this: Plugin) => void; +export interface PluginInterface { + beforeLoad?: () => void; + load(); + getName(): string; } export interface PluginOptions { - name?: string; activate?: boolean; displayName?: string; description?: string; @@ -19,42 +18,28 @@ export interface PluginOptions { [key: string]: any; } -export type PluginFn = (this: Plugin) => void; +export type PluginType = typeof Plugin; -export type PluginType = string | PluginFn | typeof Plugin; - -export class Plugin implements IPlugin { - options: PluginOptions = {}; +export abstract class Plugin implements PluginInterface { + options: O; app: Application; - callbacks: IPlugin = {}; + db: Database; - constructor(options?: PluginOptions & { app?: Application }) { - this.app = options?.app; - this.options = options; - this.callbacks = _.pick(options, ['load', 'activate']); + constructor(app: Application, options?: O) { + this.app = app; + this.db = app.db; + this.setOptions(options); } - getName() { - return this.options.name || uid(); + setOptions(options: O) { + this.options = options || ({} as any); } - async activate() { - this.options.activate = true; + getName(): string { + return this.constructor.name; } - async install() { - await this.call('install'); - } + beforeLoad() {} - async call(name: string) { - if (!this.callbacks[name]) { - return; - } - const callback = this.callbacks[name].bind(this); - await callback(); - } - - async load() { - await this.call('load'); - } + abstract load(); } diff --git a/packages/test/src/mockServer.ts b/packages/test/src/mockServer.ts index 4ed52690c..ee31cc765 100644 --- a/packages/test/src/mockServer.ts +++ b/packages/test/src/mockServer.ts @@ -55,14 +55,9 @@ interface Resource { } export class MockServer extends Application { - async loadAndSync() { + async loadAndInstall() { await this.load(); - await this.db.sync({ - force: false, - alter: { - drop: false, - }, - }); + await this.install({ clean: true }); } async cleanDb() {