Compare commits

...

11 Commits

Author SHA1 Message Date
77e27a4b93 add db migration 2025-06-20 18:59:42 +08:00
64fcacdb3d add db migration 2025-06-20 14:38:22 +08:00
13b5bc629a add db migration 2025-06-20 11:06:39 +08:00
67e4466687 add yargs bun and pm2 2025-06-19 22:10:48 +08:00
dbb090f93c add yargs bun and pm2 2025-06-19 17:42:00 +08:00
806cfa0ff6 add yargs bun and pm2 2025-06-17 22:36:38 +08:00
2190ea3066 add yargs bun and pm2 2025-06-17 15:58:22 +08:00
1ca32341fa add yargs 2025-06-16 23:29:28 +08:00
b30f9bf7d1 add yargs 2025-06-16 23:12:40 +08:00
d8c1adf08b add swagger 2025-06-16 23:01:20 +08:00
b6bc983f08 add swagger 2025-06-16 19:26:40 +08:00
53 changed files with 5615 additions and 1686 deletions

2843
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,15 +3,20 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": [
"assets/**/*"
],
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true,
"plugins": [{
"plugins": [
{
"name": "@nestjs/swagger",
"options":{
"options": {
"introspectComments": true,
"controllerKeyOfComment": "summary"
}
}]
}
]
}
}

View File

@ -6,6 +6,8 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"cli": "bun --bun src/console/bin.ts",
"dev": "cross-env NODE_ENV=development pnpm cli start -w",
"prebuild": "rimraf dist",
"build": "cross-env NODE_ENV=production nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@ -28,6 +30,7 @@
"@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0",
"chalk": "^5.4.1",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"deepmerge": "^4.3.1",
@ -37,21 +40,18 @@
"lodash": "^4.17.21",
"meilisearch": "^0.51.0",
"mysql2": "^3.14.1",
"ora": "^8.2.0",
"pm2": "^6.0.8",
"reflect-metadata": "^0.2.2",
"rimraf": "^6.0.1",
"rxjs": "^7.8.2",
"sanitize-html": "^2.17.0",
"typeorm": "^0.3.24",
"validator": "^13.15.15",
"yaml": "^2.8.0"
"yaml": "^2.8.0",
"yargs": "^18.0.0"
},
"devDependencies": {
"@babel/core": "^7.27.4",
"@babel/plugin-proposal-decorators": "^7.27.1",
"@babel/plugin-transform-runtime": "^7.27.4",
"@babel/preset-env": "^7.27.2",
"@babel/preset-typescript": "^7.27.1",
"@babel/runtime": "^7.27.6",
"@eslint/compat": "^1.3.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.29.0",
@ -69,9 +69,10 @@
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.3",
"@types/validator": "^13.15.1",
"@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.34.0",
"babel-jest": "^30.0.0",
"bun-types": "^1.2.16",
"cross-env": "^7.0.3",
"eslint": "^9.29.0",
"eslint-config-airbnb-base": "^15.0.0",
@ -86,7 +87,6 @@
"prettier": "^3.5.3",
"source-map-support": "^0.5.21",
"supertest": "^7.1.1",
"ts-babel": "^6.1.7",
"ts-jest": "29.4.0",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
@ -106,7 +106,7 @@
"<rootDir>/test/**/*.test.ts"
],
"transform": {
"^.+\\.(js|jsx)$": "babel-jest",
"^.+\\.(js|jsx)$": "ts-jest",
"^.+\\.(ts|tsx)?$": "ts-jest"
},
"collectCoverageFrom": [

File diff suppressed because it is too large Load Diff

8
src/console/bin.ts Normal file
View File

@ -0,0 +1,8 @@
import { createApp } from '@/modules/core/helpers/app';
import { buildCli } from '@/modules/core/helpers/command';
import { createOptions } from '@/options';
console.error('Raw argv:', process.argv);
console.log('This is the very beginning of bin.ts');
buildCli(createApp(createOptions));

View File

@ -1,13 +1,34 @@
export enum PostBodyType {
/**
* HTML格式
*/
HTML = 'html',
/**
* Markdown格式
*/
MD = 'markdown',
}
export enum PostOrder {
/**
*
*/
CREATED = 'createdAt',
/**
*
*/
UPDATED = 'updatedAt',
/**
*
*/
PUBLISHED = 'publishedAt',
/**
*
*/
COMMENTCOUNT = 'commentCount',
/**
*
*/
CUSTOM = 'custom',
}

View File

@ -9,12 +9,15 @@ import { SearchService } from '@/modules/content/services';
import { SanitizeService } from '@/modules/content/services/SanitizeService';
import { PostService } from '@/modules/content/services/post.service';
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
import { DatabaseModule } from '@/modules/database/database.module';
import { addSubscribers } from '@/modules/database/utils';
import { Configure } from '../config/configure';
import { defauleContentConfig } from './config';
import * as subscribers from './subscribers';
import { ContentConfig } from './types';
@Module({})
@ -23,7 +26,7 @@ export class ContentModule {
const config = await configure.get<ContentConfig>('content', defauleContentConfig);
const providers: ModuleMetadata['providers'] = [
...Object.values(services),
PostSubscriber,
...(await addSubscribers(configure, Object.values(subscribers))),
{
provide: PostService,
inject: [

View File

@ -15,8 +15,10 @@ import { ApiTags } from '@nestjs/swagger';
import { Depends } from '@/modules/restful/decorators/depend.decorator';
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
import { ContentModule } from '../content.module';
import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '../dtos/category.dto';
import { CreateCategoryDto, UpdateCategoryDto } from '../dtos/category.dto';
import { CategoryService } from '../services';
@ApiTags('Category Operate')
@ -42,7 +44,7 @@ export class CategoryController {
@SerializeOptions({ groups: ['category-list'] })
async list(
@Query()
options: QueryCategoryDto,
options: PaginateDto,
) {
return this.service.paginate(options);
}

View File

@ -15,8 +15,10 @@ import { DeleteDto } from '@/modules/content/dtos/delete.dto';
import { Depends } from '@/modules/restful/decorators/depend.decorator';
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
import { ContentModule } from '../content.module';
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '../dtos/tag.dto';
import { CreateTagDto, UpdateTagDto } from '../dtos/tag.dto';
import { TagService } from '../services';
@Depends(ContentModule)
@ -28,7 +30,7 @@ export class TagController {
@SerializeOptions({})
async list(
@Query()
options: QueryTagDto,
options: PaginateDto,
) {
return this.service.paginate(options);
}

View File

@ -16,28 +16,9 @@ import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
import { IsTreeUnique } from '@/modules/database/constraints/tree.unique.constraint';
import { IsTreeUniqueExist } from '@/modules/database/constraints/tree.unique.exist.constraint';
import { PaginateOptions } from '@/modules/database/types';
import { CategoryEntity } from '../entities';
@DtoValidation({ type: 'query' })
export class QueryCategoryDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { always: true, message: 'The current page must be greater than 1.' })
@IsInt()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, {
always: true,
message: 'The number of data displayed per page must be greater than 1.',
})
@IsInt()
@IsOptional()
limit = 10;
}
@DtoValidation({ groups: ['create'] })
export class CreateCategoryDto {
@IsTreeUnique(CategoryEntity, {

View File

@ -25,16 +25,32 @@ import { PaginateOptions } from '@/modules/database/types';
import { CategoryEntity, PostEntity, TagEntity } from '../entities';
/**
*
*/
@DtoValidation({ type: 'query' })
export class QueryPostDto implements PaginateOptions {
/**
* (全部文章:不填只查询已发布的:true只查询未发布的:false)
*/
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
@IsOptional()
isPublished?: boolean;
/**
*
*/
@MaxLength(100, {
always: true,
message: '搜索字符串长度不得超过$constraint1',
})
@IsOptional()
search?: string;
/**
* ,
*/
@IsEnum(PostOrder, {
message: `The sorting rule must be one of ${Object.values(PostOrder).join(',')}`,
})
@ -60,11 +76,17 @@ export class QueryPostDto implements PaginateOptions {
@IsOptional()
trashed?: SelectTrashMode;
/**
* ID查询此分类及其后代分类下的文章
*/
@IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' })
@IsUUID(undefined, { message: 'The ID format is incorrect' })
@IsOptional()
category?: string;
/**
* ID查询
*/
@IsUUID(undefined, { message: 'The ID format is incorrect' })
@IsOptional()
tag?: string;

View File

@ -1,34 +1,13 @@
import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDefined, IsInt, IsNotEmpty, IsOptional, IsUUID, MaxLength, Min } from 'class-validator';
import { toNumber } from 'lodash';
import { IsDefined, IsNotEmpty, IsOptional, IsUUID, MaxLength } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { IsDataExist } from '@/modules/database/constraints';
import { IsUnique } from '@/modules/database/constraints/unique.constraint';
import { IsUniqueExist } from '@/modules/database/constraints/unique.exist.constraint';
import { PaginateOptions } from '@/modules/database/types';
import { TagEntity } from '../entities';
@DtoValidation({ type: 'query' })
export class QueryTagDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { always: true, message: 'The current page must be greater than 1.' })
@IsInt()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, {
always: true,
message: 'The number of data displayed per page must be greater than 1.',
})
@IsInt()
@IsOptional()
limit = 10;
}
@DtoValidation({ groups: ['create'] })
export class CreateTagDto {
@IsUnique(TagEntity, { groups: ['create'], message: 'The label names are repeated' })

View File

@ -5,12 +5,13 @@ import {
Entity,
OneToMany,
PrimaryColumn,
Relation,
Tree,
TreeChildren,
TreeParent,
} from 'typeorm';
import type { Relation } from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
@Exclude()

View File

@ -6,12 +6,13 @@ import {
Entity,
ManyToOne,
PrimaryColumn,
Relation,
Tree,
TreeChildren,
TreeParent,
} from 'typeorm';
import type { Relation } from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
@Exclude()

View File

@ -10,10 +10,11 @@ import {
ManyToOne,
OneToMany,
PrimaryColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import type { Relation } from 'typeorm';
import { PostBodyType } from '@/modules/content/constants';
import { CategoryEntity } from '@/modules/content/entities/category.entity';
import { CommentEntity } from '@/modules/content/entities/comment.entity';

View File

@ -1,5 +1,6 @@
import { Exclude, Expose } from 'class-transformer';
import { Column, Entity, ManyToMany, PrimaryColumn, Relation } from 'typeorm';
import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm';
import type { Relation } from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';

View File

@ -10,7 +10,7 @@ import { PostEntity } from '@/modules/content/entities/post.entity';
import { CategoryRepository } from '@/modules/content/repositories';
import { PostRepository } from '@/modules/content/repositories/post.repository';
import { SearchService } from '@/modules/content/services/search.service';
import { SearchType } from '@/modules/content/types';
import type { SearchType } from '@/modules/content/types';
import { BaseService } from '@/modules/database/base/service';
import { SelectTrashMode } from '@/modules/database/constants';
import { QueryHook } from '@/modules/database/types';

View File

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

View File

@ -1,33 +1,33 @@
import { Optional } from '@nestjs/common';
import { isNil } from 'lodash';
import { DataSource, EventSubscriber, ObjectType } from 'typeorm';
import { Configure } from '@/modules/config/configure';
import { PostBodyType } from '@/modules/content/constants';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { PostRepository } from '@/modules/content/repositories/post.repository';
import { SanitizeService } from '@/modules/content/services/SanitizeService';
import { BaseSubscriber } from '@/modules/database/base/subscriber';
@EventSubscriber()
export class PostSubscriber extends BaseSubscriber<PostEntity> {
protected entity: ObjectType<PostEntity> = PostEntity;
constructor(
protected dataSource: DataSource,
protected postRepository: PostRepository,
protected configure: Configure,
@Optional() protected sanitizeService: SanitizeService,
protected _configure: Configure,
) {
super(dataSource);
super(dataSource, _configure);
}
get configure(): Configure {
return this._configure;
}
async afterLoad(entity: PostEntity) {
if (
(await this.configure.get('content.htmlEnabled')) &&
!isNil(this.sanitizeService) &&
entity.type === PostBodyType.HTML
) {
entity.body = this.sanitizeService.sanitize(entity.body);
const sanitizeService = (await this.configure.get('content.htmlEnabled'))
? this.container.get(SanitizeService)
: undefined;
if (!isNil(sanitizeService) && entity.type === PostBodyType.HTML) {
entity.body = sanitizeService.sanitize(entity.body);
}
}
}

View File

@ -0,0 +1,52 @@
import { spawn } from 'node:child_process';
import { exit } from 'process';
import { Arguments } from 'yargs';
import { getCLIConfig } from '@/modules/core/commands/helpers/config';
import { BuildCommandArguments } from '@/modules/core/commands/types';
import { CommandItem } from '@/modules/core/types';
export const createBuildCommand: CommandItem<any, BuildCommandArguments> = async (app) => ({
command: ['build', 'b'],
describe: 'Build application by nest cli.',
builder: {
nestConfig: {
type: 'string',
alias: 'n',
describe: 'nest cli config file path.',
default: 'nest-cli.json',
},
tsConfig: {
type: 'string',
alias: 't',
describe: 'typescript config file path.',
default: 'tsconfig.build.json',
},
watch: {
type: 'boolean',
alias: 'w',
describe: ' Run in watch mode (live-reload).',
default: false,
},
preserveWatchOutput: {
type: 'boolean',
alias: 'po',
describe: 'Use "preserveWatchOutput" option when using tsc watch mode',
default: false,
},
},
handler: async (args: Arguments<BuildCommandArguments>) => {
const config = getCLIConfig(args.tsConfig, args.nestConfig);
const params = ['build', '-c', args.nestConfig, '-p', args.tsConfig];
if (args.watch) {
params.push('-w');
}
if (args.preserveWatchOutput) {
params.push('po');
}
const child = spawn(config.paths.nest, params, config.subprocess.node);
child.on('exit', () => exit());
},
});

View File

@ -0,0 +1,21 @@
import { DemoCommandArguments } from '@/modules/core/commands/types';
import { CommandItem } from '@/modules/core/types';
export const DemoCommand: CommandItem<any, DemoCommandArguments> = async (app) => ({
command: ['demo', 'd'],
describe: 'a demo command',
handler: async (args: DemoCommandArguments) => {
const { configure } = app;
const appName = await configure.get<string>('app.name');
const sleep = args.sleep ? ' will to sleep' : '';
console.log(`just a demo command,app ${appName} ${sleep}`);
},
builder: {
sleep: {
type: 'boolean',
alias: 's',
describe: 'App will sleep ?',
default: false,
},
},
});

View File

@ -0,0 +1,97 @@
import { join } from 'path';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ActionOnFile, AssetEntry } from '@nestjs/cli/lib/configuration';
import chokidar, { FSWatcher } from 'chokidar';
import { get } from 'lodash';
import { CLIConfig } from '@/modules/core/commands/types';
import { toBoolean } from '@/modules/core/helpers';
export class Asset {
private watchAssetsKeyValue: { [key: string]: boolean } = {};
private watchers: FSWatcher[] = [];
private actionInProgress = false;
closeWatchers() {
const timeout = 500;
const closeFn = () => {
if (this.actionInProgress) {
this.actionInProgress = false;
setTimeout(closeFn, timeout);
} else {
this.watchers.forEach((watch) => watch.close());
}
};
setTimeout(closeFn, timeout);
}
watchAssets(config: CLIConfig, codePath: string, changer: () => void) {
const assets = get(config.options.nest, 'compilerOptions.assets', []) as AssetEntry[];
if (assets.length <= 0) {
return;
}
try {
const isWatchEnabled = toBoolean(get(config, 'watchAssets', 'src'));
const filesToWatch = assets.map<AssetEntry>((item) => {
if (typeof item === 'string') {
return {
glob: join(codePath, item),
};
}
return {
glob: join(codePath, item.include!),
exclude: item.exclude ? join(codePath, item.exclude) : undefined,
flat: item.flat,
watchAssets: item.watchAssets,
};
});
for (const file of filesToWatch) {
const option: ActionOnFile = {
action: 'change',
item: file,
path: '',
sourceRoot: codePath,
watchAssetsMode: isWatchEnabled,
};
const watcher = chokidar
.watch(file.glob, { ignored: file.exclude })
.on('add', (path) =>
this.actionOnFIle({ ...option, path, action: 'change' }, changer),
)
.on('change', (path) =>
this.actionOnFIle({ ...option, path, action: 'change' }, changer),
)
.on('unlink', (path) =>
this.actionOnFIle({ ...option, path, action: 'unlink' }, changer),
);
this.watchers.push(watcher);
}
} catch (e) {
throw new Error(
`An error occurred during the assets copying process. ${(e as any).message}`,
);
}
}
protected actionOnFIle(option: ActionOnFile, changer: () => void) {
const { action, item, path, watchAssetsMode } = option;
const isWatchEnabled = watchAssetsMode || item.watchAssets;
if (!isWatchEnabled && this.watchAssetsKeyValue[path]) {
return;
}
this.watchAssetsKeyValue[path] = true;
this.actionInProgress = true;
if (action === 'change') {
changer();
}
}
}

View File

@ -0,0 +1,113 @@
/* eslint-disable import/no-extraneous-dependencies */
import { join, resolve } from 'path';
import { exit } from 'process';
import { Configuration as NestCLIConfig } from '@nestjs/cli/lib/configuration';
import { isNil } from '@nestjs/common/utils/shared.utils';
import { existsSync, readFileSync } from 'fs-extra';
import { get, omit } from 'lodash';
import { StartOptions } from 'pm2';
import ts from 'typescript';
import { Configure } from '@/modules/config/configure';
import { CLIConfig, Pm2Option } from '@/modules/core/commands/types';
import { deepMerge, panic } from '@/modules/core/helpers';
import { AppConfig } from '@/modules/core/types';
const cwdPath = resolve(__dirname, '../../../../..');
export function getCLIConfig(
tsConfigFile: string,
nestConfigFile: string,
tsEntryFile?: string,
): CLIConfig {
let tsConfig: ts.CompilerOptions = {};
const tsConfigPath = join(cwdPath, tsConfigFile);
if (!existsSync(tsConfigPath)) {
panic(`ts config file ${tsConfigPath} not exists!`);
}
try {
const allTsConfig = JSON.parse(readFileSync(tsConfigPath, 'utf8'));
tsConfig = get(allTsConfig, 'compilerOptions', {});
} catch (error) {
panic({ error, message: 'get ts config file failed.' });
}
let nestConfig: NestCLIConfig = {};
const nestConfigPath = join(cwdPath, nestConfigFile);
if (!existsSync(nestConfigPath)) {
panic(`ts config file ${nestConfigPath} not exists!`);
}
try {
nestConfig = JSON.parse(readFileSync(nestConfigPath, 'utf8'));
} catch (error) {
panic({ error, message: 'get nest config file failed.' });
}
const dist = get(tsConfig, 'outDir', 'dist');
const src = get(nestConfig, 'sourceRoot', 'src');
const homeDir = process.env.HOME;
const paths = {
cwd: cwdPath,
dist,
src,
js: join(dist, nestConfig.entryFile ?? 'main.js'),
ts: join(src, tsEntryFile ?? 'main.ts'),
bun: `${homeDir}/.bun/bin/bun`,
nest: './node_modules/@nestjs/cli/bin/nest.js',
};
return {
options: { ts: tsConfig, nest: nestConfig },
paths,
subprocess: {
bun: {
cwd: cwdPath,
stdout: 'inherit',
env: process.env,
onExit: (proc) => {
proc.kill();
if (!isNil(proc.exitCode)) {
exit(0);
}
},
},
node: {
cwd: cwdPath,
env: process.env,
stdio: 'inherit',
},
},
};
}
export async function getPm2Config(
configure: Configure,
option: Pm2Option,
config: CLIConfig,
script: string,
): Promise<StartOptions> {
const { name, pm2: customConfig = {} } = await configure.get<AppConfig>('app');
const defaultConfig: StartOptions = {
name,
cwd: cwdPath,
script,
args: option.command,
autorestart: true,
watch: option.watch,
ignore_watch: ['node_modules'],
env: process.env,
exec_mode: 'fork',
interpreter: config.paths.bun,
};
return deepMerge(
defaultConfig,
omit(customConfig, ['name', 'cwd', 'script', 'args', 'watch', 'interpreter']),
'replace',
);
}

View File

@ -0,0 +1,125 @@
import { isNil } from '@nestjs/common/utils/shared.utils';
import { Subprocess } from 'bun';
import chalk from 'chalk';
import { pick } from 'lodash';
import pm2 from 'pm2';
import { Arguments } from 'yargs';
import { Configure } from '@/modules/config/configure';
import { Asset } from '@/modules/core/commands/helpers/asset';
import { getPm2Config } from '@/modules/core/commands/helpers/config';
import { generateSwaggerMetadata } from '@/modules/core/commands/helpers/swagger';
import { CLIConfig, StartCommandArguments } from '@/modules/core/commands/types';
import { AppConfig } from '@/modules/core/types';
export async function start(
args: Arguments<StartCommandArguments>,
config: CLIConfig,
): Promise<void> {
console.log('command start...');
const script = args.typescript ? config.paths.ts : config.paths.js;
const params = [config.paths.bun, 'run'];
if (args.watch) {
params.push('--watch');
}
if (args.debug) {
const inspectFlag =
typeof args.debug === 'string' ? `--inspect=${args.debug}` : '--inspect';
params.push(inspectFlag);
}
if (args.typescript) {
generateSwaggerMetadata(args, config, false);
}
params.push(script);
let child: Subprocess;
if (args.watch) {
const asset = new Asset();
const restart = () => {
if (!isNil(child)) {
child.kill();
}
child = Bun.spawn(params, config.subprocess.bun);
};
restart();
asset.watchAssets(config, config.paths.cwd, restart);
process.on('exit', () => {
child.kill();
asset.closeWatchers();
process.exit(0);
});
} else {
Bun.spawn(params, {
...config.subprocess.bun,
onExit(proc) {
proc.kill();
process.exit(0);
},
});
}
}
export async function startPM2(
configure: Configure,
args: Arguments<StartCommandArguments>,
config: CLIConfig,
): Promise<void> {
console.log('command startPM2...');
const { name } = await configure.get<AppConfig>('app');
const script = args.typescript ? config.paths.ts : config.paths.js;
const pm2config = await getPm2Config(
configure,
{ command: 'start', ...pick(args, ['watch', 'typescript']) },
config,
script,
);
if (pm2config.exec_mode === 'cluster' && args.typescript) {
console.log(
chalk.yellowBright(
'Cannot directly use bun to run ts code in cluster mode, so it will automatically change to fork mode.',
),
);
console.log();
console.log(
chalk.bgCyanBright(
chalk.blackBright(
'If you really need the app to be started in cluster mode, be sure to compile it into js first, and then add the --no-ts arg when running',
),
),
);
console.log();
pm2config.exec_mode = 'fork';
}
const connectCallback = (error?: any) => {
if (!isNil(error)) {
console.error(error);
process.exit(2);
}
};
const startCallback = (error?: any) => {
if (!isNil(error)) {
console.error(error);
process.exit(1);
}
pm2.disconnect();
};
const restartCallback = (error?: any) => {
if (isNil(error)) {
pm2.disconnect();
} else {
pm2.start(pm2config, (err) => startCallback(err));
}
};
pm2.connect((err: any) => {
connectCallback(err);
generateSwaggerMetadata(args, config, false);
args.restart
? pm2.restart(name, restartCallback)
: pm2.start(pm2config, (e) => startCallback(e));
});
}

View File

@ -0,0 +1,39 @@
/* eslint-disable import/no-extraneous-dependencies */
import { join } from 'path';
import { PluginMetadataGenerator } from '@nestjs/cli/lib/compiler/plugins/plugin-metadata-generator';
import { PluginOptions } from '@nestjs/cli/lib/configuration';
import { ReadonlyVisitor } from '@nestjs/swagger/dist/plugin';
import { get, isNil } from 'lodash';
import { Arguments } from 'yargs';
import { CLIConfig, StartCommandArguments } from '@/modules/core/commands/types';
export function generateSwaggerMetadata(
args: Arguments<StartCommandArguments>,
config: CLIConfig,
watch: boolean,
) {
const cliPlugins = get(config.options.nest, 'compilerOptions.plugins', []) as (
| string
| RecordAny
)[];
const swaggerPlugin = cliPlugins.find(
(item) => item === '@nest/swagger' || (item as any).name === '@nest/swagger',
);
if (!isNil(swaggerPlugin) && args.typescript) {
const srcPath = join(config.paths.cwd, config.paths.src);
const generator = new PluginMetadataGenerator();
let swaggerPluginOption: PluginOptions;
if (typeof swaggerPlugin !== 'string' && 'options' in swaggerPlugin) {
swaggerPluginOption = swaggerPlugin.options;
}
generator.generate({
visitors: [new ReadonlyVisitor({ ...swaggerPluginOption, pathToSource: srcPath })],
outputDir: srcPath,
watch,
tsconfigPath: args.tsConfig,
printDiagnostics: false,
});
}
}

View File

@ -0,0 +1,3 @@
export * from './demo.command';
export * from './build.command';
export * from './start.command';

View File

@ -0,0 +1,72 @@
import { Arguments } from 'yargs';
import { getCLIConfig } from '@/modules/core/commands/helpers/config';
import { start, startPM2 } from '@/modules/core/commands/helpers/start';
import { StartCommandArguments } from '@/modules/core/commands/types';
import { CommandItem } from '@/modules/core/types';
export const createStartCommand: CommandItem<any, StartCommandArguments> = async (app) => ({
command: ['start', 's'],
describe: 'Start app',
builder: {
nestConfig: {
type: 'string',
alias: 'n',
describe: 'nest cli config file path.',
default: 'nest-cli.json',
},
tsConfig: {
type: 'string',
alias: 't',
describe: 'typescript config file path.',
default: 'tsconfig.build.json',
},
entry: {
type: 'string',
alias: 'e',
describe:
'Specify entry file for ts runner, you can specify js entry file in nest-cli.json by entryFile.',
default: 'main.ts',
},
prod: {
type: 'boolean',
alias: 'p',
describe: 'Start app in production by pm2.',
default: false,
},
restart: {
type: 'boolean',
alias: 'r',
describe: 'Restart app(only pm2),pm2 will auto run start if process not exists.',
default: false,
},
typescript: {
type: 'boolean',
alias: 'ts',
describe: 'Run the .ts file directly.',
default: true,
},
watch: {
type: 'boolean',
alias: 'w',
describe: ' Run in watch mode (live-reload).',
default: false,
},
debug: {
type: 'boolean',
alias: 'd',
describe: 'Whether to enable debug mode, only valid for non-production environments',
default: false,
},
},
handler: async (args: Arguments<StartCommandArguments>) => {
console.log('createStartCommand handler start');
const { configure } = app;
const config = getCLIConfig(args.tsConfig, args.nestConfig, args.entry);
if (args.prod || args.restart) {
await startPM2(configure, args, config);
} else {
await start(args, config);
}
},
});

View File

@ -0,0 +1,64 @@
/* eslint-disable import/no-extraneous-dependencies */
import { SpawnOptions as NodeSpawnOptions } from 'child_process';
import { Configuration as NestCLIConfig } from '@nestjs/cli/lib/configuration';
import type { SpawnOptions as BunSpawnOptions } from 'bun';
import ts from 'typescript';
export type DemoCommandArguments = {
sleep?: boolean;
};
export interface CLIConfig {
options: {
ts: ts.CompilerOptions;
nest: NestCLIConfig;
};
paths: Record<'cwd' | 'dist' | 'src' | 'js' | 'ts' | 'bun' | 'nest', string>;
subprocess: {
bun: BunSpawnOptions.OptionsObject<any, any, any>;
node: NodeSpawnOptions;
};
}
export type StartCommandArguments = {
/**
* nest-cli.json的文件路径()
*/
nestConfig?: string;
/**
* tsconfig.build.json的文件路径()
*/
tsConfig?: string;
/**
* 使TS文件的入口文件,main.ts. js文件,nest-cli.json的entryFile指定
*/
entry?: string;
/**
* 使PM2后台静默启动生产环境
*/
prod?: boolean;
/**
* 使TS文件,
*/
typescript?: boolean;
/**
* ,使(PM2启动的生产环境下此选项无效)
*/
watch?: boolean;
/**
* debug模式,
*/
debug?: boolean | string;
/**
* (PM2进程)
*/
restart?: boolean;
};
export type Pm2Option = Pick<StartCommandArguments, 'typescript' | 'watch'> & { command: string };
export type BuildCommandArguments = Pick<StartCommandArguments, 'tsConfig' | 'nestConfig'> & {
watch?: string;
preserveWatchOutput?: boolean;
};

View File

@ -10,6 +10,8 @@ import { Configure } from '@/modules/config/configure';
import { DEFAULT_VALIDATION_CONFIG } from '@/modules/content/constants';
import { createCommands } from '@/modules/core/helpers/command';
import { CoreModule } from '../core.module';
import { AppFilter } from '../providers/app.filter';
import { AppInterceptor } from '../providers/app.interceptor';
@ -18,7 +20,7 @@ import { App, AppConfig, CreateOptions } from '../types';
import { CreateModule } from './utils';
export const app: App = { configure: new Configure() };
export const app: App = { configure: new Configure(), commands: [] };
export const createApp = (options: CreateOptions) => async (): Promise<App> => {
const { config, builder } = options;
@ -34,6 +36,7 @@ export const createApp = (options: CreateOptions) => async (): Promise<App> => {
app.container = await builder({ configure: app.configure, BootModule });
useContainer(app.container.select(BootModule), { fallbackOnErrors: true });
app.commands = await createCommands(options.commands, app as Required<App>);
return app;
};
@ -89,11 +92,11 @@ export async function createBootModule(
}
export async function startApp(
creater: () => Promise<App>,
creator: () => Promise<App>,
listened: (app: App, startTime: Date) => () => Promise<void>,
) {
const startTime = new Date();
const { container, configure } = await creater();
const { container, configure } = await creator();
app.container = container;
app.configure = configure;
const { port, host } = await configure.get<AppConfig>('app');

View File

@ -0,0 +1,55 @@
import chalk from 'chalk';
import yargs, { Arguments, CommandModule } from 'yargs';
import { hideBin } from 'yargs/helpers';
import * as coreCommands from '../commands';
import { App, CommandCollection } from '../types';
export async function buildCli(creator: () => Promise<App>) {
console.log('buildCli start');
const app = await creator();
const bin = yargs(hideBin(process.argv));
app.commands.forEach((cmd) => {
bin.command(cmd);
});
bin.usage('Usage: $0 <command> [args]')
.scriptName('cli')
.demandCommand(1, '')
.fail((msg, err, y) => {
if (!msg && !err) {
bin.showHelp();
process.exit();
}
if (msg) {
console.error(chalk.red(msg));
}
if (err) {
console.error(chalk.red(err.message));
}
process.exit();
})
.strict()
.alias('v', 'version')
.help('h')
.alias('h', 'help')
.parse();
}
export async function createCommands(
factory: () => CommandCollection,
app: Required<App>,
): Promise<CommandModule<any, any>[]> {
const collection: CommandCollection = [...factory(), ...Object.values(coreCommands)];
const commands = await Promise.all(collection.map(async (command) => command(app)));
return commands.map((command) => ({
...command,
handler: async (args: Arguments<RecordAny>) => {
await app.container.close();
await command.handler(args);
if (command.instant) {
process.exit();
}
},
}));
}

View File

@ -14,7 +14,7 @@ export function toBoolean(value?: string | boolean): boolean {
}
try {
return JSON.parse(value.toLowerCase());
} catch (error) {
} catch {
return value as unknown as boolean;
}
}
@ -41,7 +41,7 @@ export function isAsyncFunction<T, P extends Array<any>>(
callback: (...args: P) => T | Promise<T>,
): callback is (...args: P) => Promise<T> {
const AsyncFunction = (async () => {}).constructor;
return callback instanceof AsyncFunction === true;
return callback instanceof AsyncFunction;
}
export function CreateModule(
@ -76,8 +76,14 @@ export async function panic(option: PanicOption | string) {
console.log(chalk.red(`\n❌ ${option}`));
process.exit(1);
}
const { error, message, exit = true } = option;
isNil(error) ? console.log(chalk.red(`\n❌ ${message}`)) : console.log(chalk.red(error));
const { error, message, spinner, exit = true } = option;
if (isNil(error)) {
isNil(spinner)
? console.log(chalk.red(`\n❌ ${message}`))
: spinner.succeed(chalk.red(`\n❌ ${message}`));
} else {
isNil(spinner) ? console.log(chalk.red(error)) : spinner.fail(chalk.red(error));
}
if (exit) {
process.exit(1);
}

View File

@ -1,6 +1,10 @@
import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common';
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { Ora } from 'ora';
import { StartOptions } from 'pm2';
import { CommandModule } from 'yargs';
import { Configure } from '../config/configure';
import { ConfigStorageOption, ConfigureFactory } from '../config/types';
@ -8,6 +12,8 @@ export type App = {
container?: NestFastifyApplication;
configure: Configure;
commands: CommandModule<RecordAny, RecordAny>[];
};
export interface CreateOptions {
@ -30,6 +36,8 @@ export interface CreateOptions {
storage: ConfigStorageOption;
};
commands: () => CommandCollection;
}
export interface ContainerBuilder {
@ -52,6 +60,8 @@ export interface AppConfig {
url?: string;
prefix?: string;
pm2?: Omit<StartOptions, 'name' | 'cwd' | 'script' | 'args' | 'interpreter' | 'watch'>;
}
export interface PanicOption {
@ -60,4 +70,20 @@ export interface PanicOption {
error?: any;
exit?: boolean;
spinner?: Ora;
}
export interface CommandOption<T = RecordAny, P = RecordAny> extends CommandModule<T, P> {
instant?: boolean;
}
export type CommandItem<T = RecordAny, P = RecordAny> = (
app: Required<App>,
) => Promise<CommandOption<T, P>>;
export type CommandCollection = Array<CommandItem<any, any>>;
export interface CreateOption {
commands: () => CommandCollection;
}

View File

@ -1,4 +1,5 @@
import { Optional } from '@nestjs/common';
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { isNil } from 'lodash';
import {
DataSource,
@ -17,6 +18,10 @@ import {
UpdateEvent,
} from 'typeorm';
import { Configure } from '@/modules/config/configure';
import { app } from '@/modules/core/helpers/app';
import { RepositoryType } from '../types';
import { getCustomRepository } from '../utils';
@ -36,12 +41,25 @@ export abstract class BaseSubscriber<T extends ObjectLiteral>
{
protected abstract entity: ObjectType<T>;
protected constructor(@Optional() protected dataSource?: DataSource) {
protected constructor(
@Optional() protected dataSource?: DataSource,
@Optional() protected _configure?: Configure,
) {
if (!isNil(this.dataSource)) {
this.dataSource.subscribers.push(this);
}
}
get configure() {
return isNil(this._configure)
? this.container.get(Configure, { strict: false })
: this._configure;
}
get container(): NestFastifyApplication {
return app.container;
}
protected getDataSource(event: SubscriberEvent<T>) {
return this.dataSource ?? event.connection;
}

View File

@ -0,0 +1,34 @@
import { Arguments } from 'yargs';
import { CommandItem } from '@/modules/core/types';
import { MigrationCreateHandler } from '@/modules/database/commands/migration.create.handler';
import { MigrationCreateArguments } from '@/modules/database/commands/types';
/**
*
*
* @param configure
* @constructor
*/
export const CreateMigrationCommand: CommandItem<any, MigrationCreateArguments> = async ({
configure,
}) => ({
source: true,
command: [],
describe: 'Creates a new migration file',
builder: {
connection: {
type: 'string',
alias: 'c',
describe: 'Connection name of typeorm to connect database.',
},
name: {
type: 'string',
alias: 'n',
describe: 'Name of the migration class.',
demandOption: true,
},
},
handler: async (args: Arguments<MigrationCreateArguments>) =>
MigrationCreateHandler(configure, args),
});

View File

@ -0,0 +1,38 @@
import chalk from 'chalk';
import { isNil } from 'lodash';
import ora from 'ora';
import { Arguments } from 'yargs';
import { Configure } from '@/modules/config/configure';
import { panic } from '@/modules/core/helpers';
import { TypeormMigrationCreate } from '@/modules/database/commands/typeorm.migration.create';
import { MigrationCreateArguments } from '@/modules/database/commands/types';
import { DBOptions, TypeormOption } from '@/modules/database/types';
/**
*
*
* @param configure
* @param args
* @constructor
*/
export async function MigrationCreateHandler(
configure: Configure,
args: Arguments<MigrationCreateArguments>,
) {
const spinner = ora('start to create migration').start();
const cname = args.connection ?? 'default';
try {
const { connections = [] } = await configure.get<DBOptions>('database');
const dbConfig: TypeormOption = connections.find(({ name }) => name === cname);
if (isNil(dbConfig)) {
await panic(`database connection ${cname} not found`);
}
const runner = new TypeormMigrationCreate();
console.log();
await runner.handler({ name: cname, dir: dbConfig.paths.migration });
spinner.start(chalk.greenBright.underline('\n 👍 Finished create migration'));
} catch (e) {
await panic({ spinner, message: 'Create migration failed!', error: e });
}
}

View File

@ -0,0 +1,58 @@
import { Arguments } from 'yargs';
import { CommandItem } from '@/modules/core/types';
import { MigrationGenerateHandler } from '@/modules/database/commands/migration.generate.handler';
import { MigrationGenerateArguments } from '@/modules/database/commands/types';
export const GenerateMigrationCommand: CommandItem<any, MigrationGenerateArguments> = async ({
configure,
}) => ({
instant: true,
command: ['db:migration:generate', 'dbmg'],
describe: 'Auto generates a new migration file with sql needs to be executed to update schema.',
builder: {
connection: {
type: 'string',
alias: 'c',
describe: 'Connection name of typeorm to connect database.',
},
name: {
type: 'string',
alias: 'n',
describe: 'Name of the migration class.',
},
run: {
type: 'boolean',
alias: 'r',
describe: 'Run migration after generated.',
default: false,
},
dir: {
type: 'string',
alias: 'd',
describe: 'Which directory where migration should be generated.',
},
pretty: {
type: 'boolean',
alias: 'p',
describe: 'Pretty-print generated SQL',
default: false,
},
dryrun: {
type: 'boolean',
alias: 'dr',
describe: 'Prints out the contents of the migration instead of writing it to a file',
default: false,
},
check: {
type: 'boolean',
alias: 'ch',
describe:
'Verifies that the current database is up to date and that no migrations are needed. Otherwise exits with code 1.',
default: false,
},
} as const,
handler: async (args: Arguments<MigrationGenerateArguments>) =>
MigrationGenerateHandler(configure, args),
});

View File

@ -0,0 +1,52 @@
import chalk from 'chalk';
import { isNil, pick } from 'lodash';
import ora from 'ora';
import { DataSource, DataSourceOptions } from 'typeorm';
import { Arguments } from 'yargs';
import { Configure } from '@/modules/config/configure';
import { getRandomString, panic } from '@/modules/core/helpers';
import { MigrationRunHandler } from '@/modules/database/commands/migration.run.handler';
import { TypeormMigrationGenerate } from '@/modules/database/commands/typeorm.migration.generate';
import { MigrationGenerateArguments } from '@/modules/database/commands/types';
import { DBOptions } from '../types';
export async function MigrationGenerateHandler(
configure: Configure,
args: Arguments<MigrationGenerateArguments>,
) {
await MigrationRunHandler(configure, { connection: args.connection } as any);
console.log();
const spinner = ora('Start to generate migration');
const cname = args.connection ?? 'default';
try {
spinner.start();
console.log();
const { connections = [] }: DBOptions = await configure.get<DBOptions>('database');
const dbConfig = connections.find(({ name }) => name === cname);
if (isNil(dbConfig)) {
await panic(`Database connection named ${cname} not exists!`);
}
console.log();
const runner = new TypeormMigrationGenerate();
const dataSource = new DataSource({ ...dbConfig } as DataSourceOptions);
console.log();
await runner.handler({
name: args.name ?? getRandomString(6),
dir: dbConfig.paths.migration,
dataSource,
...pick(args, ['pretty', 'outputJs', 'dryrun', 'check']),
});
if (dataSource.isInitialized) {
await dataSource.destroy();
}
spinner.succeed(chalk.greenBright.underline('\n 👍 Finished generate migration'));
if (args.run) {
console.log();
await MigrationRunHandler(configure, { connection: args.connection } as any);
}
} catch (error) {
await panic({ spinner, message: 'Generate migration failed!', error });
}
}

View File

@ -0,0 +1,53 @@
import { Arguments } from 'yargs';
import { CommandItem } from '@/modules/core/types';
import { MigrationRunHandler } from '@/modules/database/commands/migration.run.handler';
import { MigrationRunArguments } from '@/modules/database/commands/types';
/**
*
* @param configure
* @constructor
*/
export const RunMigrationCommand: CommandItem<any, MigrationRunArguments> = async ({
configure,
}) => ({
source: true,
command: ['db:migration:run', 'dbmr'],
describe: 'Runs all pending migrations.',
builder: {
connection: {
type: 'string',
alias: 'c',
describe: 'Connection name of typeorm to connect database.',
},
transaction: {
type: 'string',
alias: 't',
describe:
'Indicates if transaction should be used or not for migration run/revert/reflash. Enabled by default.',
default: 'default',
},
fake: {
type: 'boolean',
alias: 'f',
describe:
'Fakes running the migrations if table schema has already been changed manually or externally ' +
'(e.g. through another project)',
},
refresh: {
type: 'boolean',
alias: 'r',
describe: 'drop database schema and run migration',
default: false,
},
onlydrop: {
type: 'boolean',
alias: 'o',
describe: 'only drop database schema',
default: false,
},
} as const,
handler: async (args: Arguments<MigrationRunArguments>) => MigrationRunHandler(configure, args),
});

View File

@ -0,0 +1,75 @@
import { join } from 'path';
import chalk from 'chalk';
import { isNil } from 'lodash';
import ora from 'ora';
import { DataSource, DataSourceOptions } from 'typeorm';
import { Arguments } from 'yargs';
import { Configure } from '@/modules/config/configure';
import { panic } from '@/modules/core/helpers';
import { TypeormMigrationRun } from '@/modules/database/commands/typeorm.migration.run';
import { MigrationRunArguments } from '@/modules/database/commands/types';
import { DBOptions } from '@/modules/database/types';
/**
*
* @param configure
* @param args
* @constructor
*/
export async function MigrationRunHandler(
configure: Configure,
args: Arguments<MigrationRunArguments>,
) {
const spinner = ora('Start to run migration...');
const cname = args.connection ?? 'default';
let dataSource: DataSource | undefined;
try {
spinner.start();
const { connections = [] }: DBOptions = await configure.get<DBOptions>('database');
const dbConfig = connections.find(({ name }) => name === cname);
if (isNil(dbConfig)) {
await panic(`Database connection named ${cname} not exists!`);
}
const dropSchema = args.refresh || args.onlydrop;
console.log();
const runner = new TypeormMigrationRun();
dataSource = new DataSource({ ...dbConfig } as DataSourceOptions);
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
const options = {
subscribers: [],
synchronize: false,
migrationsRun: false,
dropSchema,
logging: ['error'],
migrations: [
join(dbConfig.paths.migration, '**/*.ts'),
join(dbConfig.paths.migration, '**/*.js'),
],
} as any;
if (dropSchema) {
dataSource.setOptions(options);
await dataSource.initialize();
await dataSource.destroy();
spinner.succeed(chalk.greenBright.underline('\n 👍 Finished drop database schema'));
if (args.onlydrop) {
process.exit();
}
}
dataSource.setOptions({ ...options, dropSchema: false });
await dataSource.initialize();
console.log();
await runner.handler({ dataSource, transaction: args.transaction, fake: args.fake });
spinner.succeed(chalk.greenBright.underline('\n 👍 Finished run migrations'));
} catch (error) {
await panic({ spinner, message: 'Run migrations failed!', error });
} finally {
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
}
}

View File

@ -0,0 +1,45 @@
import { resolve } from 'path';
import chalk from 'chalk';
import { CommandUtils } from 'typeorm/commands/CommandUtils';
import { PlatformTools } from 'typeorm/platform/PlatformTools';
import { camelCase } from 'typeorm/util/StringUtils';
import { MigrationCreateOptions } from '@/modules/database/commands/types';
type HandleOptions = MigrationCreateOptions & { dir: string };
export class TypeormMigrationCreate {
async handler(args: HandleOptions) {
try {
const timestamp = new Date().getTime();
const directory = args.dir.startsWith('/')
? args.dir
: resolve(process.cwd(), args.dir);
const fileContent = TypeormMigrationCreate.getTemplate(args.name, timestamp);
const fileName = `${timestamp}-${args.name}`;
const filePath = `${directory}/${fileName}`;
await CommandUtils.createFile(`${filePath}.ts`, fileContent);
console.log(
`Migration ${chalk.blue(`${filePath}.ts`)} has been generated successfully.`,
);
} catch (e) {
PlatformTools.logCmdErr('Error during migration creation:', e);
process.exit(1);
}
}
protected static getTemplate(name: string, timestamp: number): string {
return `import typeorm = require('typeorm');
class ${camelCase(name, true)}${timestamp} implements typeorm.MigrationInterface {
public async up(queryRunner: typeorm.QueryRunner): Promise<void> {
}
public async down(queryRunner: typeorm.QueryRunner): Promise<void> {
}
}
`;
}
}

View File

@ -0,0 +1,152 @@
import { resolve } from 'path';
import chalk from 'chalk';
import { upperFirst } from 'lodash';
import { format } from 'mysql2';
import { DataSource } from 'typeorm';
import { CommandUtils } from 'typeorm/commands/CommandUtils';
import { PlatformTools } from 'typeorm/platform/PlatformTools';
import { camelCase } from 'typeorm/util/StringUtils';
import { MigrationGenerateOptions } from '@/modules/database/commands/types';
type HandlerOptions = MigrationGenerateOptions & { dataSource: DataSource } & { dir: string };
export class TypeormMigrationGenerate {
async handler(args: HandlerOptions) {
const timestamp = new Date().getTime();
const fileExt = '.ts';
const directory = args.dir.startsWith('/') ? args.dir : resolve(process.cwd(), args.dir);
const filename = `${timestamp}-${args.name}`;
const filePath = `${directory}/${filename}${fileExt}`;
const { dataSource } = args;
try {
dataSource.setOptions({
synchronize: false,
migrationsRun: false,
dropSchema: false,
logging: false,
});
await dataSource.initialize();
const upSqls: string[] = [];
const downSqls: string[] = [];
try {
const sqlInMemory = await dataSource.driver.createSchemaBuilder().log();
if (args.pretty) {
sqlInMemory.upQueries.forEach((upSql) => {
upSql.query = TypeormMigrationGenerate.prettifyQuery(upSql.query);
});
sqlInMemory.downQueries.forEach((downSql) => {
downSql.query = TypeormMigrationGenerate.prettifyQuery(downSql.query);
});
}
sqlInMemory.upQueries.forEach((upQuery) => {
upSqls.push(
` await queryRunner.query(\`${upQuery.query.replace(
/`/g,
'\\`',
)}\`${TypeormMigrationGenerate.queryParams(upQuery.parameters)});`,
);
});
sqlInMemory.downQueries.forEach((downQuery) => {
downSqls.push(
` await queryRunner.query(\`${downQuery.query.replace(
/`/g,
'\\`',
)}\`${TypeormMigrationGenerate.queryParams(downQuery.parameters)});`,
);
});
} finally {
await dataSource.destroy();
}
if (!upSqls.length) {
console.log(chalk.green(`No changes in database schema were found`));
process.exit(0);
}
const fileContent = TypeormMigrationGenerate.getTemplate(
args.name,
timestamp,
upSqls,
downSqls.reverse(),
);
if (args.check) {
console.log(
chalk.yellow(
`Unexpected changes in database schema were found in check mode:\n\n${chalk.white(
fileContent,
)}`,
),
);
process.exit(1);
}
if (args.dryrun) {
console.log(
chalk.green(
`Migration ${chalk.blue(
filePath,
)} has content:\n\n${chalk.white(fileContent)}`,
),
);
} else {
await CommandUtils.createFile(filePath, fileContent);
console.log(
chalk.green(
`Migration ${chalk.blue(filePath)} has been generated successfully.`,
),
);
}
} catch (e) {
PlatformTools.logCmdErr('Error during migration generation:', e);
process.exit(1);
}
}
protected static queryParams(params: any[] | undefined): string {
if (!params || !params.length) {
return '';
}
return `,${JSON.stringify(params)}`;
}
protected static prettifyQuery(query: string) {
const formatQuery = format(query, { indent: ' ' });
return `\n${formatQuery.replace(/^/gm, ' ')}\n `;
}
protected static getTemplate(
name: string,
timestamp: number,
upSqls: string[],
downSqls: string[],
): string {
const migrationName = `${camelCase(upperFirst(name), true)}${timestamp}`;
return `import typeorm = require('typeorm');
class ${migrationName} implements typeorm.MigrationInterface {
name = '${migrationName}'
public async up(queryRunner: typeorm.QueryRunner): Promise<void> {
${upSqls.join(`
`)}
}
public async down(queryRunner: typeorm.QueryRunner): Promise<void> {
${downSqls.join(`
`)}
}
}
module.exports = ${migrationName}
`;
}
}

View File

@ -0,0 +1,28 @@
import { DataSource } from 'typeorm';
import { MigrationRunOptions } from '@/modules/database/commands/types';
type HandlerOptions = MigrationRunOptions & { dataSource: DataSource };
export class TypeormMigrationRun {
async handler({ transaction, fake, dataSource }: HandlerOptions) {
const options = {
transaction: dataSource.options.migrationsTransactionMode ?? 'all',
fake,
};
switch (transaction) {
case 'all':
options.transaction = 'all';
break;
case 'none':
case 'false':
options.transaction = 'none';
break;
case 'each':
options.transaction = 'each';
break;
default:
}
await dataSource.runMigrations(options);
}
}

View File

@ -0,0 +1,57 @@
import { Arguments } from 'yargs';
/**
*
*/
export type TypeOrmArguments = Arguments<{ connection?: string }>;
/**
*
*/
export type MigrationCreateArguments = TypeOrmArguments & MigrationCreateOptions;
/**
*
*/
export interface MigrationCreateOptions {
name?: string;
}
/**
*
*/
export type MigrationGenerateArguments = TypeOrmArguments & MigrationGenerateOptions;
/**
*
*/
export interface MigrationGenerateOptions {
name?: string;
run?: boolean;
pretty?: boolean;
dryrun?: boolean;
check?: boolean;
}
/**
*
*/
export type MigrationRunArguments = TypeOrmArguments & MigrationRunOptions;
/**
*
*/
export interface MigrationRunOptions extends MigrationRevertOptions {
refresh?: boolean;
onlydrop?: boolean;
}
/**
*
*/
export interface MigrationRevertOptions {
transaction?: string;
fake?: boolean;
}

View File

@ -1,3 +1,5 @@
import { resolve } from 'path';
import { ConfigureFactory, ConfigureRegister } from '../config/types';
import { createConnectionOptions } from '../config/utils';
import { deepMerge } from '../core/helpers';
@ -18,7 +20,11 @@ export const createDBConfig: (
export const createDBOptions = (options: DBConfig) => {
const newOptions: DBOptions = {
common: deepMerge(
{ charset: 'utf8mb4', logging: ['error'] },
{
charset: 'utf8mb4',
logging: ['error'],
paths: { migration: resolve(__dirname, '../../database/migrations') },
},
options.common ?? {},
'replace',
),
@ -29,7 +35,7 @@ export const createDBOptions = (options: DBConfig) => {
const newOption = { ...connection, entities };
return deepMerge(
newOptions.common,
{ ...newOption, autoLoadEntities: true } as any,
{ ...newOption, autoLoadEntities: true, synchronize: false } as any,
'replace',
) as TypeormOption;
});

View File

@ -17,12 +17,27 @@ export enum SelectTrashMode {
}
export enum OrderType {
/**
*
*/
ASC = 'ASC',
/**
*
*/
DESC = 'DESC',
}
export enum TreeChildrenResolve {
/**
*
*/
DELETE = 'delete',
/**
*
*/
UP = 'up',
/**
*
*/
ROOT = 'root',
}

View File

@ -2,8 +2,8 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import {
FindTreeOptions,
ObjectLiteral,
SelectQueryBuilder,
Repository,
SelectQueryBuilder,
TreeRepository,
} from 'typeorm';
@ -70,13 +70,21 @@ export type RepositoryType<T extends ObjectLiteral> =
| BaseTreeRepository<T>;
export type DBConfig = {
common: RecordAny;
common: RecordAny & DBAdditionalOption;
connections: Array<TypeOrmModuleOptions & { name?: string }>;
};
export type TypeormOption = Omit<TypeOrmModuleOptions, 'name' | 'migrations'> & { name: string };
export type TypeormOption = Omit<TypeOrmModuleOptions, 'name' | 'migrations'> & {
name: string;
} & DBAdditionalOption;
export type DBOptions = RecordAny & {
common: RecordAny;
connections: TypeormOption[];
};
type DBAdditionalOption = {
paths?: {
migration?: string;
};
};

View File

@ -1,7 +1,16 @@
import { Type } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
import { isArray, isNil } from 'lodash';
import { DataSource, ObjectLiteral, ObjectType, Repository, SelectQueryBuilder } from 'typeorm';
import { OrderQueryType, PaginateOptions, PaginateReturn } from '@/modules/database/types';
import { Configure } from '@/modules/config/configure';
import {
DBOptions,
OrderQueryType,
PaginateOptions,
PaginateReturn,
} from '@/modules/database/types';
import { CUSTOM_REPOSITORY_METADATA } from './constants';
@ -97,3 +106,48 @@ export const getCustomRepository = <P extends Repository<T>, T extends ObjectLit
const base = dataSource.getRepository<ObjectType<any>>(entity);
return new Repo(base.target, base.manager, base.queryRunner) as P;
};
export const addEntities = async (
configure: Configure,
entities: EntityClassOrSchema[] = [],
dataSource = 'default',
) => {
const database = await configure.get<DBOptions>('database');
if (isNil(database)) {
throw new Error('Database not exists');
}
const dbConfig = database.connections.find(({ name }) => name === dataSource);
if (isNil(dbConfig)) {
throw new Error(`Database connection ${dataSource} not exists`);
}
const oldEntities = (dbConfig.entities ?? []) as ObjectLiteral[];
const newEntities = database.connections.map((conn) =>
conn.name === dataSource ? { ...conn, entities: [...oldEntities, ...entities] } : conn,
);
configure.set('database.connections', newEntities);
return TypeOrmModule.forFeature(entities, dataSource);
};
export async function addSubscribers(
configure: Configure,
subscribers: Type<any>[] = [],
dataSource = 'default',
) {
const database = await configure.get<DBOptions>('database');
if (isNil(database)) {
throw new Error('Database not exists');
}
const dbConfig = database.connections.find(({ name }) => name === dataSource);
if (isNil(dbConfig)) {
throw new Error(`Database connection ${dataSource} not exists`);
}
const oldSubscribers = (dbConfig.subscribers ?? []) as any[];
const newSubscribers = database.connections.map((conn) =>
conn.name === dataSource
? { ...conn, subscribers: [...oldSubscribers, subscribers] }
: conn,
);
configure.set('database.connections', newSubscribers);
return subscribers;
}

View File

@ -1,7 +1,7 @@
import { ConfigureFactory, ConfigureRegister } from '../config/types';
import { createConnectionOptions } from '../config/utils';
import { MeiliConfig } from './types';
import type { MeiliConfig } from './types';
export const createMeiliConfig: (
registre: ConfigureRegister<RePartial<MeiliConfig>>,

View File

@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { isNil } from 'lodash';
import { MeiliSearch } from 'meilisearch';
import { MeiliConfig } from '@/modules/meilisearch/types';
import type { MeiliConfig } from '@/modules/meilisearch/types';
@Injectable()
export class MeiliService {

View File

@ -1,5 +1,5 @@
import { Transform } from 'class-transformer';
import { Min, IsNumber, IsOptional } from 'class-validator';
import { IsNumber, IsOptional, Min } from 'class-validator';
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
@ -14,7 +14,7 @@ export class PaginateDto implements PaginateOptions {
*
*/
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '当前页必须大于1' })
@Min(1, { message: 'The current page must be greater than 1.' })
@IsNumber()
@IsOptional()
page?: number = 1;
@ -23,7 +23,7 @@ export class PaginateDto implements PaginateOptions {
*
*/
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '每页显示数据必须大于1' })
@Min(1, { message: 'The number of data displayed per page must be greater than 1.' })
@IsNumber()
@IsOptional()
limit?: number = 10;

View File

@ -17,6 +17,7 @@ import { RestfulModule } from './modules/restful/restful.module';
import { ApiConfig } from './modules/restful/types';
export const createOptions: CreateOptions = {
commands: () => [],
config: { factories: configs as any, storage: { enable: true } },
modules: async (configure) => [
DatabaseModule.forRoot(configure),

View File

@ -21,6 +21,8 @@ import { createOptions } from '@/options';
import { generateRandomNumber, generateUniqueRandomNumbers } from './generate-mock-data';
import { categoriesData, commentData, INIT_DATA, postData, tagData } from './test-data';
const URL_PREFIX = '/api/v1/content';
describe('nest app test', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
@ -79,7 +81,7 @@ describe('nest app test', () => {
ids.map(async (id) => {
const result = await app.inject({
method: 'GET',
url: `/category/${id}`,
url: `${URL_PREFIX}/category/${id}`,
});
categories.push(result.json());
return result.json();
@ -119,7 +121,7 @@ describe('nest app test', () => {
it('create category without name', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {},
});
expect(result.json()).toEqual({
@ -135,7 +137,7 @@ describe('nest app test', () => {
it('create category with long name', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: { name: 'A'.repeat(30) },
});
expect(result.json()).toEqual({
@ -149,7 +151,7 @@ describe('nest app test', () => {
const rootCategory = categories.find((c) => !c.parent);
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: { name: rootCategory.name },
});
expect(result.json()).toEqual({
@ -163,7 +165,7 @@ describe('nest app test', () => {
const testData = categories.find((item) => !isNil(item.parent));
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: testData.name,
parent: testData.parent.id,
@ -179,7 +181,7 @@ describe('nest app test', () => {
it('create category with invalid parent id format', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
parent: 'invalid-uuid',
@ -198,7 +200,7 @@ describe('nest app test', () => {
it('create category with non-existent parent id', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
parent: '74e655b3-b69a-42ae-a101-41c224386e74',
@ -214,7 +216,7 @@ describe('nest app test', () => {
it('create category with negative custom order', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
customOrder: -1,
@ -230,7 +232,7 @@ describe('nest app test', () => {
it('create category with empty name', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: { name: '' },
});
expect(result.json()).toEqual({
@ -243,7 +245,7 @@ describe('nest app test', () => {
it('create category with whitespace name', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: { name: ' ' },
});
expect(result.json()).toEqual({
@ -257,7 +259,7 @@ describe('nest app test', () => {
const name = 'A'.repeat(25);
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: { name },
});
expect(result.statusCode).toEqual(201);
@ -265,14 +267,14 @@ describe('nest app test', () => {
expect(category.name).toBe(name);
await app.inject({
method: 'DELETE',
url: `/category/${result.json().id}`,
url: `${URL_PREFIX}/category/${result.json().id}`,
});
});
it('create category with name one char over limit (26 chars)', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: { name: 'A'.repeat(26) },
});
expect(result.json()).toEqual({
@ -286,7 +288,7 @@ describe('nest app test', () => {
const rootCategory = categories.find((c) => !c.parent);
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: { name: rootCategory.name },
});
expect(result.json()).toEqual({
@ -302,7 +304,7 @@ describe('nest app test', () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: existingChild.name,
parent: parentCategory.id,
@ -322,7 +324,7 @@ describe('nest app test', () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: childName,
parent: parent2.id,
@ -331,14 +333,14 @@ describe('nest app test', () => {
expect(result.statusCode).toEqual(201);
await app.inject({
method: 'DELETE',
url: `/category/${result.json().id}`,
url: `${URL_PREFIX}/category/${result.json().id}`,
});
});
it('create category with parent set to null string', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'Root Category',
parent: 'null', // 注意:这里传递字符串 'null'
@ -349,14 +351,14 @@ describe('nest app test', () => {
expect(category.parent).toBeNull();
await app.inject({
method: 'DELETE',
url: `/category/${result.json().id}`,
url: `${URL_PREFIX}/category/${result.json().id}`,
});
});
it('create category with parent set to null value', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'Root Category',
parent: null,
@ -367,14 +369,14 @@ describe('nest app test', () => {
expect(category.parent).toBeNull();
await app.inject({
method: 'DELETE',
url: `/category/${result.json().id}`,
url: `${URL_PREFIX}/category/${result.json().id}`,
});
});
it('create category with empty parent id', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
parent: '',
@ -393,7 +395,7 @@ describe('nest app test', () => {
it('create category with malformed UUID parent id', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
parent: 'not-a-valid-uuid-123',
@ -412,7 +414,7 @@ describe('nest app test', () => {
it('create category with customOrder as string', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
customOrder: '10', // 字符串形式的数字
@ -423,14 +425,14 @@ describe('nest app test', () => {
expect(category.customOrder).toBe(10);
await app.inject({
method: 'DELETE',
url: `/category/${result.json().id}`,
url: `${URL_PREFIX}/category/${result.json().id}`,
});
});
it('create category with customOrder as float', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
customOrder: 5.5,
@ -446,7 +448,7 @@ describe('nest app test', () => {
it('create category with customOrder as negative number', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
customOrder: -1,
@ -462,7 +464,7 @@ describe('nest app test', () => {
it('create category with customOrder as zero', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
customOrder: 0,
@ -473,14 +475,14 @@ describe('nest app test', () => {
expect(category.customOrder).toBe(0);
await app.inject({
method: 'DELETE',
url: `/category/${result.json().id}`,
url: `${URL_PREFIX}/category/${result.json().id}`,
});
});
it('create category with customOrder as large number', async () => {
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Category',
customOrder: 999999,
@ -489,7 +491,7 @@ describe('nest app test', () => {
expect(result.statusCode).toEqual(201);
await app.inject({
method: 'DELETE',
url: `/category/${result.json().id}`,
url: `${URL_PREFIX}/category/${result.json().id}`,
});
});
@ -497,7 +499,7 @@ describe('nest app test', () => {
const parent = categories.find((c) => !c.parent);
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'Valid New Category',
parent: parent.id,
@ -511,7 +513,7 @@ describe('nest app test', () => {
expect(category.customOrder).toBe(5);
await app.inject({
method: 'DELETE',
url: `/category/${result.json().id}`,
url: `${URL_PREFIX}/category/${result.json().id}`,
});
});
@ -520,7 +522,7 @@ describe('nest app test', () => {
const category = categories[0];
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
name: 'Invalid Category',
parent: category.id,
@ -535,7 +537,7 @@ describe('nest app test', () => {
it('update category without id', async () => {
const result = await app.inject({
method: 'PATCH',
url: '/category',
url: `${URL_PREFIX}/category`,
body: { name: 'Updated Category' },
});
expect(result.json()).toEqual({
@ -552,7 +554,7 @@ describe('nest app test', () => {
it('update category with invalid id format', async () => {
const result = await app.inject({
method: 'PATCH',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
id: 'invalid-uuid',
name: 'Updated Category',
@ -572,7 +574,7 @@ describe('nest app test', () => {
it('update category with non-existent id', async () => {
const result = await app.inject({
method: 'PATCH',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
id: '74e655b3-b69a-42ae-a101-41c224386e74',
name: 'Updated Category',
@ -585,7 +587,7 @@ describe('nest app test', () => {
const category = categories[0];
const result = await app.inject({
method: 'PATCH',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
id: category.id,
name: 'A'.repeat(30),
@ -604,7 +606,7 @@ describe('nest app test', () => {
const result = await app.inject({
method: 'PATCH',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
id: child1.id,
name: child2.name,
@ -621,7 +623,7 @@ describe('nest app test', () => {
const category = categories[0];
const result = await app.inject({
method: 'PATCH',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
id: category.id,
parent: 'invalid-uuid',
@ -641,7 +643,7 @@ describe('nest app test', () => {
const category = categories[0];
const result = await app.inject({
method: 'PATCH',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
id: category.id,
parent: '74e655b3-b69a-42ae-a101-41c224386e74',
@ -658,7 +660,7 @@ describe('nest app test', () => {
const category = categories[0];
const result = await app.inject({
method: 'PATCH',
url: '/category',
url: `${URL_PREFIX}/category`,
body: {
id: category.id,
customOrder: -1,
@ -675,7 +677,7 @@ describe('nest app test', () => {
it('query categories with invalid page', async () => {
const result = await app.inject({
method: 'GET',
url: '/category?page=0',
url: `${URL_PREFIX}/category?page=0`,
});
expect(result.json()).toEqual({
message: ['The current page must be greater than 1.'],
@ -687,7 +689,7 @@ describe('nest app test', () => {
it('query categories with invalid limit', async () => {
const result = await app.inject({
method: 'GET',
url: '/category?limit=0',
url: `${URL_PREFIX}/category?limit=0`,
});
expect(result.json()).toEqual({
message: ['The number of data displayed per page must be greater than 1.'],
@ -707,7 +709,7 @@ describe('nest app test', () => {
it('create tag without name', async () => {
const result = await app.inject({
method: 'POST',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: {},
});
expect(result.json()).toEqual({
@ -724,7 +726,7 @@ describe('nest app test', () => {
it('create tag with long name', async () => {
const result = await app.inject({
method: 'POST',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: { name: 'A'.repeat(256) },
});
expect(result.json()).toEqual({
@ -738,7 +740,7 @@ describe('nest app test', () => {
const existingTag = tags[0];
const result = await app.inject({
method: 'POST',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: { name: existingTag.name },
});
expect(result.json()).toEqual({
@ -751,7 +753,7 @@ describe('nest app test', () => {
it('create tag with long description', async () => {
const result = await app.inject({
method: 'POST',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: {
name: 'NewTag',
desc: 'A'.repeat(501),
@ -768,7 +770,7 @@ describe('nest app test', () => {
it('update tag without id', async () => {
const result = await app.inject({
method: 'PATCH',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: { name: 'Updated Tag' },
});
expect(result.json()).toEqual({
@ -785,7 +787,7 @@ describe('nest app test', () => {
it('update tag with invalid id format', async () => {
const result = await app.inject({
method: 'PATCH',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: {
id: 'invalid-uuid',
name: 'Updated Tag',
@ -801,7 +803,7 @@ describe('nest app test', () => {
it('update tag with non-existent id', async () => {
const result = await app.inject({
method: 'PATCH',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: {
id: '74e655b3-b69a-42ae-a101-41c224386e74',
name: 'Updated Tag',
@ -818,7 +820,7 @@ describe('nest app test', () => {
const tag = tags[0];
const result = await app.inject({
method: 'PATCH',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: {
id: tag.id,
name: 'A'.repeat(256),
@ -835,7 +837,7 @@ describe('nest app test', () => {
const [tag1, tag2] = tags;
const result = await app.inject({
method: 'PATCH',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: {
id: tag1.id,
name: tag2.name,
@ -852,7 +854,7 @@ describe('nest app test', () => {
const tag = tags[0];
const result = await app.inject({
method: 'PATCH',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: {
id: tag.id,
desc: 'A'.repeat(501),
@ -878,7 +880,7 @@ describe('nest app test', () => {
it('create post without title', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: { body: 'Post content' },
});
expect(result.json()).toEqual({
@ -894,7 +896,7 @@ describe('nest app test', () => {
it('create post without body', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: { title: 'New Post' },
});
expect(result.json()).toEqual({
@ -907,7 +909,7 @@ describe('nest app test', () => {
it('create post with long title', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
title: 'A'.repeat(256),
body: 'Post content',
@ -923,7 +925,7 @@ describe('nest app test', () => {
it('create post with long summary', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
title: 'New Post',
body: 'Content',
@ -940,7 +942,7 @@ describe('nest app test', () => {
it('create post with invalid category', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
title: 'New Post',
body: 'Content',
@ -957,7 +959,7 @@ describe('nest app test', () => {
it('create post with non-existent category', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
title: 'New Post',
body: 'Content',
@ -974,7 +976,7 @@ describe('nest app test', () => {
it('create post with invalid tag format', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
title: 'New Post',
body: 'Content',
@ -991,7 +993,7 @@ describe('nest app test', () => {
it('create post with non-existent tag', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
title: 'New Post',
body: 'Content',
@ -1008,7 +1010,7 @@ describe('nest app test', () => {
it('create post with long keyword', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
title: 'New Post',
body: 'Content',
@ -1025,7 +1027,7 @@ describe('nest app test', () => {
it('create post with negative custom order', async () => {
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
title: 'New Post',
body: 'Content',
@ -1043,7 +1045,7 @@ describe('nest app test', () => {
it('update post without id', async () => {
const result = await app.inject({
method: 'PATCH',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: { title: 'Updated Post' },
});
expect(result.json()).toEqual({
@ -1059,7 +1061,7 @@ describe('nest app test', () => {
it('update post with invalid id format', async () => {
const result = await app.inject({
method: 'PATCH',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
id: 'invalid-uuid',
title: 'Updated Post',
@ -1078,7 +1080,7 @@ describe('nest app test', () => {
it('update post with non-existent id', async () => {
const result = await app.inject({
method: 'PATCH',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
id: '74e655b3-b69a-42ae-a101-41c224386e74',
title: 'Updated Post non-existent id',
@ -1095,7 +1097,7 @@ describe('nest app test', () => {
const post = posts[0];
const result = await app.inject({
method: 'PATCH',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: {
id: post.id,
title: 'A'.repeat(256),
@ -1122,7 +1124,7 @@ describe('nest app test', () => {
const post = posts[0];
const result = await app.inject({
method: 'POST',
url: '/comment',
url: `${URL_PREFIX}/comment`,
body: { post: post.id },
});
expect(result.json()).toEqual({
@ -1138,7 +1140,7 @@ describe('nest app test', () => {
it('create comment without post', async () => {
const result = await app.inject({
method: 'POST',
url: '/comment',
url: `${URL_PREFIX}/comment`,
body: { body: 'Test comment' },
});
expect(result.json()).toEqual({
@ -1152,7 +1154,7 @@ describe('nest app test', () => {
const post = posts[0];
const result = await app.inject({
method: 'POST',
url: '/comment',
url: `${URL_PREFIX}/comment`,
body: {
body: 'A'.repeat(1001),
post: post.id,
@ -1168,7 +1170,7 @@ describe('nest app test', () => {
it('create comment with invalid post format', async () => {
const result = await app.inject({
method: 'POST',
url: '/comment',
url: `${URL_PREFIX}/comment`,
body: {
body: 'Test comment',
post: 'invalid-uuid',
@ -1184,7 +1186,7 @@ describe('nest app test', () => {
it('create comment with non-existent post', async () => {
const result = await app.inject({
method: 'POST',
url: '/comment',
url: `${URL_PREFIX}/comment`,
body: {
body: 'Test comment',
post: '74e655b3-b69a-42ae-a101-41c224386e74',
@ -1201,7 +1203,7 @@ describe('nest app test', () => {
const post = posts[0];
const result = await app.inject({
method: 'POST',
url: '/comment',
url: `${URL_PREFIX}/comment`,
body: {
body: 'Test comment',
post: post.id,
@ -1219,7 +1221,7 @@ describe('nest app test', () => {
const post = posts[0];
const result = await app.inject({
method: 'POST',
url: '/comment',
url: `${URL_PREFIX}/comment`,
body: {
body: 'Test comment',
post: post.id,
@ -1251,7 +1253,7 @@ async function addCategory(
const item = data[index];
const result = await app.inject({
method: 'POST',
url: '/category',
url: `${URL_PREFIX}/category`,
body: { ...pick(item, ['name', 'customOrder']), parent: parentId },
});
const addedItem: CategoryEntity = result.json();
@ -1269,7 +1271,7 @@ async function addTag(app: NestFastifyApplication, data: RecordAny[]): Promise<T
const item = data[index];
const result = await app.inject({
method: 'POST',
url: '/tag',
url: `${URL_PREFIX}/tag`,
body: item,
});
const addedItem: TagEntity = result.json();
@ -1293,7 +1295,7 @@ async function addPost(
item.tags = generateUniqueRandomNumbers(0, tags.length - 1, 3).map((idx) => tags[idx]);
const result = await app.inject({
method: 'POST',
url: '/posts',
url: `${URL_PREFIX}/posts`,
body: item,
});
const addedItem: PostEntity = result.json();
@ -1323,7 +1325,7 @@ async function addComment(
: undefined;
const result = await app.inject({
method: 'POST',
url: '/comment',
url: `${URL_PREFIX}/comment`,
body: item,
});
const addedItem = result.json();

View File

@ -31,6 +31,7 @@
"lib": ["esnext", "DOM", "ScriptHost", "WebWorker"],
"baseUrl": ".",
"outDir": "./dist",
"types": ["bun-types", "@types/jest"],
"paths": {
"@/*": ["./src/*"]
}