@tndhuy/create-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/cli.js +534 -0
- package/package.json +37 -0
- package/templates/mongo/.env.example +32 -0
- package/templates/mongo/Dockerfile +64 -0
- package/templates/mongo/docker-compose.yml +35 -0
- package/templates/mongo/eslint.config.mjs +35 -0
- package/templates/mongo/nest-cli.json +8 -0
- package/templates/mongo/package.json +105 -0
- package/templates/mongo/src/app.module.ts +59 -0
- package/templates/mongo/src/common/decorators/public-api.decorator.ts +9 -0
- package/templates/mongo/src/common/decorators/raw-response.decorator.ts +4 -0
- package/templates/mongo/src/common/filters/http-exception.filter.spec.ts +95 -0
- package/templates/mongo/src/common/filters/http-exception.filter.ts +43 -0
- package/templates/mongo/src/common/filters/rpc-exception.filter.ts +18 -0
- package/templates/mongo/src/common/index.ts +5 -0
- package/templates/mongo/src/common/interceptors/timeout.interceptor.ts +32 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.spec.ts +52 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.ts +25 -0
- package/templates/mongo/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
- package/templates/mongo/src/common/middleware/correlation-id.middleware.ts +26 -0
- package/templates/mongo/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
- package/templates/mongo/src/infrastructure/cache/redis.module.ts +9 -0
- package/templates/mongo/src/infrastructure/cache/redis.service.spec.ts +174 -0
- package/templates/mongo/src/infrastructure/cache/redis.service.ts +121 -0
- package/templates/mongo/src/infrastructure/config/config.module.ts +36 -0
- package/templates/mongo/src/infrastructure/config/environment.validation.spec.ts +100 -0
- package/templates/mongo/src/infrastructure/config/environment.validation.ts +21 -0
- package/templates/mongo/src/infrastructure/database/mongodb.module.ts +17 -0
- package/templates/mongo/src/infrastructure/health/health.controller.ts +46 -0
- package/templates/mongo/src/infrastructure/health/health.module.ts +12 -0
- package/templates/mongo/src/infrastructure/health/redis.health-indicator.ts +20 -0
- package/templates/mongo/src/instrumentation.spec.ts +24 -0
- package/templates/mongo/src/instrumentation.ts +44 -0
- package/templates/mongo/src/main.ts +102 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.command.ts +3 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.handler.ts +20 -0
- package/templates/mongo/src/modules/example/application/commands/delete-item.command.ts +3 -0
- package/templates/mongo/src/modules/example/application/commands/delete-item.handler.ts +15 -0
- package/templates/mongo/src/modules/example/application/dtos/create-item.dto.ts +9 -0
- package/templates/mongo/src/modules/example/application/dtos/item.response.dto.ts +9 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.handler.ts +16 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.query.ts +3 -0
- package/templates/mongo/src/modules/example/application/queries/list-items.handler.ts +16 -0
- package/templates/mongo/src/modules/example/application/queries/list-items.query.ts +3 -0
- package/templates/mongo/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
- package/templates/mongo/src/modules/example/domain/item-name.value-object.ts +18 -0
- package/templates/mongo/src/modules/example/domain/item.entity.spec.ts +48 -0
- package/templates/mongo/src/modules/example/domain/item.entity.ts +19 -0
- package/templates/mongo/src/modules/example/domain/item.repository.interface.ts +10 -0
- package/templates/mongo/src/modules/example/example.module.ts +31 -0
- package/templates/mongo/src/modules/example/infrastructure/.gitkeep +0 -0
- package/templates/mongo/src/modules/example/infrastructure/persistence/mongoose-item.repository.ts +42 -0
- package/templates/mongo/src/modules/example/infrastructure/persistence/schemas/item.schema.ts +15 -0
- package/templates/mongo/src/modules/example/presenter/item.controller.ts +52 -0
- package/templates/mongo/src/shared/base/aggregate-root.spec.ts +44 -0
- package/templates/mongo/src/shared/base/aggregate-root.ts +20 -0
- package/templates/mongo/src/shared/base/domain-event.ts +6 -0
- package/templates/mongo/src/shared/base/entity.spec.ts +36 -0
- package/templates/mongo/src/shared/base/entity.ts +13 -0
- package/templates/mongo/src/shared/base/index.ts +5 -0
- package/templates/mongo/src/shared/base/repository.interface.ts +6 -0
- package/templates/mongo/src/shared/base/value-object.spec.ts +39 -0
- package/templates/mongo/src/shared/base/value-object.ts +13 -0
- package/templates/mongo/src/shared/dto/pagination.dto.spec.ts +49 -0
- package/templates/mongo/src/shared/dto/pagination.dto.ts +37 -0
- package/templates/mongo/src/shared/dto/response.dto.ts +13 -0
- package/templates/mongo/src/shared/exceptions/app.exception.spec.ts +59 -0
- package/templates/mongo/src/shared/exceptions/app.exception.ts +19 -0
- package/templates/mongo/src/shared/exceptions/error-codes.ts +9 -0
- package/templates/mongo/src/shared/index.ts +7 -0
- package/templates/mongo/src/shared/logger/logger.module.ts +12 -0
- package/templates/mongo/src/shared/logger/logger.service.ts +48 -0
- package/templates/mongo/src/shared/logger/pino.config.ts +86 -0
- package/templates/mongo/src/shared/validation-options.ts +38 -0
- package/templates/mongo/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
- package/templates/mongo/src/shared/valueobjects/date.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
- package/templates/mongo/src/shared/valueobjects/id.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/index.ts +4 -0
- package/templates/mongo/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
- package/templates/mongo/src/shared/valueobjects/number.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
- package/templates/mongo/src/shared/valueobjects/string.valueobject.ts +14 -0
- package/templates/mongo/tsconfig.build.json +4 -0
- package/templates/mongo/tsconfig.json +23 -0
- package/templates/postgres/.env.example +32 -0
- package/templates/postgres/Dockerfile +64 -0
- package/templates/postgres/eslint.config.mjs +35 -0
- package/templates/postgres/nest-cli.json +8 -0
- package/templates/postgres/package.json +103 -0
- package/templates/postgres/prisma/schema.prisma +14 -0
- package/templates/postgres/prisma.config.ts +11 -0
- package/templates/postgres/src/app.module.ts +34 -0
- package/templates/postgres/src/common/decorators/public-api.decorator.ts +9 -0
- package/templates/postgres/src/common/decorators/raw-response.decorator.ts +4 -0
- package/templates/postgres/src/common/filters/http-exception.filter.spec.ts +95 -0
- package/templates/postgres/src/common/filters/http-exception.filter.ts +43 -0
- package/templates/postgres/src/common/filters/rpc-exception.filter.ts +18 -0
- package/templates/postgres/src/common/index.ts +5 -0
- package/templates/postgres/src/common/interceptors/timeout.interceptor.ts +32 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.spec.ts +52 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.ts +25 -0
- package/templates/postgres/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
- package/templates/postgres/src/common/middleware/correlation-id.middleware.ts +26 -0
- package/templates/postgres/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
- package/templates/postgres/src/infrastructure/cache/redis.module.ts +9 -0
- package/templates/postgres/src/infrastructure/cache/redis.service.spec.ts +174 -0
- package/templates/postgres/src/infrastructure/cache/redis.service.ts +121 -0
- package/templates/postgres/src/infrastructure/config/config.module.ts +36 -0
- package/templates/postgres/src/infrastructure/config/environment.validation.spec.ts +100 -0
- package/templates/postgres/src/infrastructure/config/environment.validation.ts +21 -0
- package/templates/postgres/src/infrastructure/database/inject-prisma.decorator.ts +4 -0
- package/templates/postgres/src/infrastructure/database/prisma.module.ts +9 -0
- package/templates/postgres/src/infrastructure/database/prisma.service.ts +21 -0
- package/templates/postgres/src/infrastructure/health/health.controller.ts +46 -0
- package/templates/postgres/src/infrastructure/health/health.module.ts +12 -0
- package/templates/postgres/src/infrastructure/health/prisma.health-indicator.ts +19 -0
- package/templates/postgres/src/infrastructure/health/redis.health-indicator.ts +20 -0
- package/templates/postgres/src/instrumentation.spec.ts +24 -0
- package/templates/postgres/src/instrumentation.ts +44 -0
- package/templates/postgres/src/main.ts +102 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.command.ts +3 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.handler.ts +20 -0
- package/templates/postgres/src/modules/example/application/commands/delete-item.command.ts +3 -0
- package/templates/postgres/src/modules/example/application/commands/delete-item.handler.ts +15 -0
- package/templates/postgres/src/modules/example/application/dtos/create-item.dto.ts +9 -0
- package/templates/postgres/src/modules/example/application/dtos/item.response.dto.ts +9 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.handler.ts +16 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.query.ts +3 -0
- package/templates/postgres/src/modules/example/application/queries/list-items.handler.ts +16 -0
- package/templates/postgres/src/modules/example/application/queries/list-items.query.ts +3 -0
- package/templates/postgres/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
- package/templates/postgres/src/modules/example/domain/item-name.value-object.ts +18 -0
- package/templates/postgres/src/modules/example/domain/item.entity.spec.ts +48 -0
- package/templates/postgres/src/modules/example/domain/item.entity.ts +19 -0
- package/templates/postgres/src/modules/example/domain/item.repository.interface.ts +10 -0
- package/templates/postgres/src/modules/example/example.module.ts +26 -0
- package/templates/postgres/src/modules/example/infrastructure/.gitkeep +0 -0
- package/templates/postgres/src/modules/example/infrastructure/persistence/prisma-item.repository.ts +34 -0
- package/templates/postgres/src/modules/example/presenter/item.controller.ts +52 -0
- package/templates/postgres/src/shared/base/aggregate-root.spec.ts +44 -0
- package/templates/postgres/src/shared/base/aggregate-root.ts +20 -0
- package/templates/postgres/src/shared/base/domain-event.ts +6 -0
- package/templates/postgres/src/shared/base/entity.spec.ts +36 -0
- package/templates/postgres/src/shared/base/entity.ts +13 -0
- package/templates/postgres/src/shared/base/index.ts +5 -0
- package/templates/postgres/src/shared/base/repository.interface.ts +6 -0
- package/templates/postgres/src/shared/base/value-object.spec.ts +39 -0
- package/templates/postgres/src/shared/base/value-object.ts +13 -0
- package/templates/postgres/src/shared/dto/pagination.dto.spec.ts +49 -0
- package/templates/postgres/src/shared/dto/pagination.dto.ts +37 -0
- package/templates/postgres/src/shared/dto/response.dto.ts +13 -0
- package/templates/postgres/src/shared/exceptions/app.exception.spec.ts +59 -0
- package/templates/postgres/src/shared/exceptions/app.exception.ts +19 -0
- package/templates/postgres/src/shared/exceptions/error-codes.ts +9 -0
- package/templates/postgres/src/shared/index.ts +7 -0
- package/templates/postgres/src/shared/logger/logger.module.ts +12 -0
- package/templates/postgres/src/shared/logger/logger.service.ts +48 -0
- package/templates/postgres/src/shared/logger/pino.config.ts +86 -0
- package/templates/postgres/src/shared/validation-options.ts +38 -0
- package/templates/postgres/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
- package/templates/postgres/src/shared/valueobjects/date.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
- package/templates/postgres/src/shared/valueobjects/id.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/index.ts +4 -0
- package/templates/postgres/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
- package/templates/postgres/src/shared/valueobjects/number.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
- package/templates/postgres/src/shared/valueobjects/string.valueobject.ts +14 -0
- package/templates/postgres/tsconfig.build.json +4 -0
- package/templates/postgres/tsconfig.json +23 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Reflector } from '@nestjs/core';
|
|
2
|
+
import { of } from 'rxjs';
|
|
3
|
+
import { RAW_RESPONSE_KEY, TransformInterceptor } from './transform.interceptor';
|
|
4
|
+
|
|
5
|
+
function createMockContext(isRaw: boolean) {
|
|
6
|
+
const reflector = new Reflector();
|
|
7
|
+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(isRaw);
|
|
8
|
+
const context = {
|
|
9
|
+
getHandler: () => jest.fn(),
|
|
10
|
+
getClass: () => jest.fn(),
|
|
11
|
+
} as any;
|
|
12
|
+
return { reflector, context };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('TransformInterceptor', () => {
|
|
16
|
+
it('should wrap controller return value in { success: true, data }', (done) => {
|
|
17
|
+
const { reflector, context } = createMockContext(false);
|
|
18
|
+
const interceptor = new TransformInterceptor(reflector);
|
|
19
|
+
const next = { handle: () => of({ id: '1', name: 'test' }) };
|
|
20
|
+
|
|
21
|
+
interceptor.intercept(context, next).subscribe((result) => {
|
|
22
|
+
expect(result).toEqual({ success: true, data: { id: '1', name: 'test' } });
|
|
23
|
+
done();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should NOT wrap when @RawResponse() is used', (done) => {
|
|
28
|
+
const { reflector, context } = createMockContext(true);
|
|
29
|
+
const interceptor = new TransformInterceptor(reflector);
|
|
30
|
+
const next = { handle: () => of({ status: 'ok' }) };
|
|
31
|
+
|
|
32
|
+
interceptor.intercept(context, next).subscribe((result) => {
|
|
33
|
+
expect(result).toEqual({ status: 'ok' });
|
|
34
|
+
done();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should wrap null return value in { success: true, data: null }', (done) => {
|
|
39
|
+
const { reflector, context } = createMockContext(false);
|
|
40
|
+
const interceptor = new TransformInterceptor(reflector);
|
|
41
|
+
const next = { handle: () => of(null) };
|
|
42
|
+
|
|
43
|
+
interceptor.intercept(context, next).subscribe((result) => {
|
|
44
|
+
expect(result).toEqual({ success: true, data: null });
|
|
45
|
+
done();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should use RAW_RESPONSE_KEY constant', () => {
|
|
50
|
+
expect(RAW_RESPONSE_KEY).toBe('raw_response');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
import { map } from 'rxjs/operators';
|
|
5
|
+
import { PUBLIC_API_KEY } from '../decorators/public-api.decorator';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
|
|
9
|
+
constructor(private readonly reflector: Reflector) {}
|
|
10
|
+
|
|
11
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
12
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_API_KEY, [
|
|
13
|
+
context.getHandler(),
|
|
14
|
+
context.getClass(),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// Internal first: If not explicitly marked as Public API, return RAW data
|
|
18
|
+
if (!isPublic) {
|
|
19
|
+
return next.handle();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Public API: Wrap response in a standardized success object
|
|
23
|
+
return next.handle().pipe(map((data) => ({ success: true, data })));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CORRELATION_ID_HEADER, CorrelationIdMiddleware } from './correlation-id.middleware';
|
|
2
|
+
|
|
3
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
4
|
+
|
|
5
|
+
function createMockReq(headers: Record<string, string> = {}): any {
|
|
6
|
+
return { headers: { ...headers } };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function createMockRes(): any {
|
|
10
|
+
const headers: Record<string, string> = {};
|
|
11
|
+
return {
|
|
12
|
+
setHeader: (name: string, value: string) => {
|
|
13
|
+
headers[name] = value;
|
|
14
|
+
},
|
|
15
|
+
_headers: headers,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('CorrelationIdMiddleware', () => {
|
|
20
|
+
let middleware: CorrelationIdMiddleware;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
middleware = new CorrelationIdMiddleware();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should generate UUID v4 when X-Correlation-ID header is absent', () => {
|
|
27
|
+
const req = createMockReq();
|
|
28
|
+
const res = createMockRes();
|
|
29
|
+
const next = jest.fn();
|
|
30
|
+
|
|
31
|
+
middleware.use(req, res, next);
|
|
32
|
+
|
|
33
|
+
const correlationId = req.headers[CORRELATION_ID_HEADER.toLowerCase()];
|
|
34
|
+
expect(correlationId).toMatch(UUID_PATTERN);
|
|
35
|
+
expect(next).toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should preserve existing X-Correlation-ID header', () => {
|
|
39
|
+
const req = createMockReq({ 'x-correlation-id': 'abc-123' });
|
|
40
|
+
const res = createMockRes();
|
|
41
|
+
const next = jest.fn();
|
|
42
|
+
|
|
43
|
+
middleware.use(req, res, next);
|
|
44
|
+
|
|
45
|
+
expect(req.headers['x-correlation-id']).toBe('abc-123');
|
|
46
|
+
expect(res._headers[CORRELATION_ID_HEADER]).toBe('abc-123');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should always set X-Correlation-ID on the response header', () => {
|
|
50
|
+
const req = createMockReq();
|
|
51
|
+
const res = createMockRes();
|
|
52
|
+
const next = jest.fn();
|
|
53
|
+
|
|
54
|
+
middleware.use(req, res, next);
|
|
55
|
+
|
|
56
|
+
expect(res._headers[CORRELATION_ID_HEADER]).toBeDefined();
|
|
57
|
+
expect(res._headers[CORRELATION_ID_HEADER]).toMatch(UUID_PATTERN);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should call next()', () => {
|
|
61
|
+
const req = createMockReq();
|
|
62
|
+
const res = createMockRes();
|
|
63
|
+
const next = jest.fn();
|
|
64
|
+
|
|
65
|
+
middleware.use(req, res, next);
|
|
66
|
+
|
|
67
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { NextFunction, Request, Response } from 'express';
|
|
4
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
5
|
+
|
|
6
|
+
export const REQUEST_ID_HEADER = 'X-Request-Id';
|
|
7
|
+
export const requestIdStorage = new AsyncLocalStorage<string>();
|
|
8
|
+
|
|
9
|
+
@Injectable()
|
|
10
|
+
export class CorrelationIdMiddleware implements NestMiddleware {
|
|
11
|
+
use(req: Request, res: Response, next: NextFunction): void {
|
|
12
|
+
const requestId =
|
|
13
|
+
(req.headers[REQUEST_ID_HEADER.toLowerCase()] as string) ||
|
|
14
|
+
(req.headers['x-correlation-id'] as string) ||
|
|
15
|
+
randomUUID();
|
|
16
|
+
|
|
17
|
+
// Ensure it's in headers for downstream and logs
|
|
18
|
+
req.headers[REQUEST_ID_HEADER.toLowerCase()] = requestId;
|
|
19
|
+
res.setHeader(REQUEST_ID_HEADER, requestId);
|
|
20
|
+
|
|
21
|
+
// Wrap in AsyncLocalStorage so it's accessible everywhere
|
|
22
|
+
requestIdStorage.run(requestId, () => {
|
|
23
|
+
next();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { RedisService } from './redis.service';
|
|
2
|
+
import { trace } from '@opentelemetry/api';
|
|
3
|
+
|
|
4
|
+
// ---- ioredis mock ----
|
|
5
|
+
const mockRedisOn = jest.fn();
|
|
6
|
+
const mockGet = jest.fn();
|
|
7
|
+
const mockSet = jest.fn();
|
|
8
|
+
const mockDel = jest.fn();
|
|
9
|
+
const mockExpire = jest.fn();
|
|
10
|
+
const mockQuit = jest.fn().mockResolvedValue('OK');
|
|
11
|
+
|
|
12
|
+
const mockRedisInstance = {
|
|
13
|
+
on: mockRedisOn,
|
|
14
|
+
get: mockGet,
|
|
15
|
+
set: mockSet,
|
|
16
|
+
del: mockDel,
|
|
17
|
+
expire: mockExpire,
|
|
18
|
+
quit: mockQuit,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
jest.mock('ioredis', () => {
|
|
22
|
+
return jest.fn().mockImplementation(() => mockRedisInstance);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ---- cockatiel mock ----
|
|
26
|
+
// circuitBreaker returns a policy whose execute() just calls the fn directly
|
|
27
|
+
const mockPolicyExecute = jest.fn((fn: () => Promise<unknown>) => fn());
|
|
28
|
+
|
|
29
|
+
jest.mock('cockatiel', () => ({
|
|
30
|
+
circuitBreaker: jest.fn(() => ({ execute: mockPolicyExecute })),
|
|
31
|
+
ConsecutiveBreaker: jest.fn(),
|
|
32
|
+
handleAll: {},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
describe('RedisService', () => {
|
|
36
|
+
let service: RedisService;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
jest.clearAllMocks();
|
|
40
|
+
// Re-establish required mock implementations after clearAllMocks()
|
|
41
|
+
mockQuit.mockResolvedValue('OK');
|
|
42
|
+
mockPolicyExecute.mockImplementation((fn: () => Promise<unknown>) => fn());
|
|
43
|
+
process.env.REDIS_URL = 'redis://localhost:6379';
|
|
44
|
+
service = new RedisService();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
await service.onModuleDestroy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('constructor', () => {
|
|
52
|
+
it('should initialise the Redis client immediately in the constructor', () => {
|
|
53
|
+
// The Redis constructor mock should have been called once
|
|
54
|
+
const RedisMock = require('ioredis');
|
|
55
|
+
expect(RedisMock).toHaveBeenCalledWith(
|
|
56
|
+
process.env.REDIS_URL,
|
|
57
|
+
expect.objectContaining({ enableOfflineQueue: false }),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should register connect and error event listeners on the client', () => {
|
|
62
|
+
expect(mockRedisOn).toHaveBeenCalledWith('connect', expect.any(Function));
|
|
63
|
+
expect(mockRedisOn).toHaveBeenCalledWith('error', expect.any(Function));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('get', () => {
|
|
68
|
+
it('should return the cached value for a key', async () => {
|
|
69
|
+
mockGet.mockResolvedValue('cached-value');
|
|
70
|
+
const result = await service.get('some-key');
|
|
71
|
+
expect(mockGet).toHaveBeenCalledWith('some-key');
|
|
72
|
+
expect(result).toBe('cached-value');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return null when key does not exist', async () => {
|
|
76
|
+
mockGet.mockResolvedValue(null);
|
|
77
|
+
const result = await service.get('missing-key');
|
|
78
|
+
expect(result).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('set', () => {
|
|
83
|
+
it('should set a key-value pair without TTL', async () => {
|
|
84
|
+
mockSet.mockResolvedValue('OK');
|
|
85
|
+
await service.set('key', 'value');
|
|
86
|
+
expect(mockSet).toHaveBeenCalledWith('key', 'value');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should set a key-value pair with TTL in seconds', async () => {
|
|
90
|
+
mockSet.mockResolvedValue('OK');
|
|
91
|
+
await service.set('key', 'value', 60);
|
|
92
|
+
expect(mockSet).toHaveBeenCalledWith('key', 'value', 'EX', 60);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('del', () => {
|
|
97
|
+
it('should delete a key from the cache', async () => {
|
|
98
|
+
mockDel.mockResolvedValue(1);
|
|
99
|
+
await service.del('key-to-delete');
|
|
100
|
+
expect(mockDel).toHaveBeenCalledWith('key-to-delete');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('expire', () => {
|
|
105
|
+
it('should set an expiry time on an existing key', async () => {
|
|
106
|
+
mockExpire.mockResolvedValue(1);
|
|
107
|
+
await service.expire('some-key', 300);
|
|
108
|
+
expect(mockExpire).toHaveBeenCalledWith('some-key', 300);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('execute (circuit breaker)', () => {
|
|
113
|
+
it('should delegate operations through the circuit breaker policy', async () => {
|
|
114
|
+
mockGet.mockResolvedValue('value');
|
|
115
|
+
await service.get('test');
|
|
116
|
+
expect(mockPolicyExecute).toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should propagate errors when the circuit breaker is open', async () => {
|
|
120
|
+
const circuitOpenError = new Error('Circuit breaker is open');
|
|
121
|
+
mockPolicyExecute.mockRejectedValueOnce(circuitOpenError);
|
|
122
|
+
await expect(service.get('any-key')).rejects.toThrow(
|
|
123
|
+
'Circuit breaker is open',
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('getClient', () => {
|
|
129
|
+
it('should return the underlying Redis client instance', () => {
|
|
130
|
+
const client = service.getClient();
|
|
131
|
+
expect(client).toBe(mockRedisInstance);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('onModuleDestroy', () => {
|
|
136
|
+
it('should quit the Redis client on module destroy', async () => {
|
|
137
|
+
await service.onModuleDestroy();
|
|
138
|
+
expect(mockQuit).toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('OTel tracing', () => {
|
|
143
|
+
it('should expose the OTel trace API (smoke test)', () => {
|
|
144
|
+
// When OTel SDK is not initialized, getTracer returns a no-op tracer
|
|
145
|
+
const t = trace.getTracer('test');
|
|
146
|
+
expect(t).toBeDefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('get() should still resolve correctly with OTel no-op spans', async () => {
|
|
150
|
+
mockGet.mockResolvedValue('traced-value');
|
|
151
|
+
const result = await service.get('otel-key');
|
|
152
|
+
expect(result).toBe('traced-value');
|
|
153
|
+
expect(mockGet).toHaveBeenCalledWith('otel-key');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('set() should still resolve correctly with OTel no-op spans', async () => {
|
|
157
|
+
mockSet.mockResolvedValue('OK');
|
|
158
|
+
await service.set('otel-key', 'otel-value', 30);
|
|
159
|
+
expect(mockSet).toHaveBeenCalledWith('otel-key', 'otel-value', 'EX', 30);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('del() should still resolve correctly with OTel no-op spans', async () => {
|
|
163
|
+
mockDel.mockResolvedValue(1);
|
|
164
|
+
await service.del('otel-key');
|
|
165
|
+
expect(mockDel).toHaveBeenCalledWith('otel-key');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('expire() should still resolve correctly with OTel no-op spans', async () => {
|
|
169
|
+
mockExpire.mockResolvedValue(1);
|
|
170
|
+
await service.expire('otel-key', 120);
|
|
171
|
+
expect(mockExpire).toHaveBeenCalledWith('otel-key', 120);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
|
|
2
|
+
import Redis from 'ioredis';
|
|
3
|
+
import { circuitBreaker, ConsecutiveBreaker, CircuitBreakerPolicy, handleAll } from 'cockatiel';
|
|
4
|
+
import { trace } from '@opentelemetry/api';
|
|
5
|
+
|
|
6
|
+
const tracer = trace.getTracer('redis-service');
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class RedisService implements OnModuleDestroy {
|
|
10
|
+
private readonly client: Redis;
|
|
11
|
+
private readonly logger = new Logger(RedisService.name);
|
|
12
|
+
private readonly policy: CircuitBreakerPolicy;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
// Initialize circuit breaker
|
|
16
|
+
this.policy = circuitBreaker(handleAll, {
|
|
17
|
+
halfOpenAfter: 10_000,
|
|
18
|
+
breaker: new ConsecutiveBreaker(5),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Initialize Redis client in constructor — NOT onModuleInit.
|
|
22
|
+
// ConfigModule validates REDIS_URL at startup (fail-fast),
|
|
23
|
+
// so it is guaranteed available when CacheModule loads.
|
|
24
|
+
this.client = new Redis(process.env.REDIS_URL!, {
|
|
25
|
+
commandTimeout: 5000,
|
|
26
|
+
connectTimeout: 5000,
|
|
27
|
+
enableOfflineQueue: false,
|
|
28
|
+
maxRetriesPerRequest: 3,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.client.on('connect', () => this.logger.log('Redis connected'));
|
|
32
|
+
this.client.on('error', (err) => this.logger.error('Redis error', err.message));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async onModuleDestroy(): Promise<void> {
|
|
36
|
+
await this.client?.quit();
|
|
37
|
+
this.logger.log('Redis disconnected');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getClient(): Redis {
|
|
41
|
+
return this.client;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async execute<T>(fn: (client: Redis) => Promise<T>): Promise<T> {
|
|
45
|
+
return this.policy.execute(() => fn(this.client));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async get(key: string): Promise<string | null> {
|
|
49
|
+
return tracer.startActiveSpan('redis.get', async (span) => {
|
|
50
|
+
try {
|
|
51
|
+
span.setAttribute('db.system', 'redis');
|
|
52
|
+
span.setAttribute('db.operation', 'get');
|
|
53
|
+
span.setAttribute('db.redis.key', key);
|
|
54
|
+
return await this.execute((c) => c.get(key));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
span.recordException(error as Error);
|
|
57
|
+
throw error;
|
|
58
|
+
} finally {
|
|
59
|
+
span.end();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
|
65
|
+
return tracer.startActiveSpan('redis.set', async (span) => {
|
|
66
|
+
try {
|
|
67
|
+
span.setAttribute('db.system', 'redis');
|
|
68
|
+
span.setAttribute('db.operation', 'set');
|
|
69
|
+
span.setAttribute('db.redis.key', key);
|
|
70
|
+
if (ttlSeconds !== undefined) {
|
|
71
|
+
span.setAttribute('db.redis.ttl', ttlSeconds);
|
|
72
|
+
}
|
|
73
|
+
await this.execute(async (c) => {
|
|
74
|
+
if (ttlSeconds) {
|
|
75
|
+
await c.set(key, value, 'EX', ttlSeconds);
|
|
76
|
+
} else {
|
|
77
|
+
await c.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
span.recordException(error as Error);
|
|
82
|
+
throw error;
|
|
83
|
+
} finally {
|
|
84
|
+
span.end();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async del(key: string): Promise<void> {
|
|
90
|
+
return tracer.startActiveSpan('redis.del', async (span) => {
|
|
91
|
+
try {
|
|
92
|
+
span.setAttribute('db.system', 'redis');
|
|
93
|
+
span.setAttribute('db.operation', 'del');
|
|
94
|
+
span.setAttribute('db.redis.key', key);
|
|
95
|
+
await this.execute((c) => c.del(key).then(() => undefined));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
span.recordException(error as Error);
|
|
98
|
+
throw error;
|
|
99
|
+
} finally {
|
|
100
|
+
span.end();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async expire(key: string, ttlSeconds: number): Promise<void> {
|
|
106
|
+
return tracer.startActiveSpan('redis.expire', async (span) => {
|
|
107
|
+
try {
|
|
108
|
+
span.setAttribute('db.system', 'redis');
|
|
109
|
+
span.setAttribute('db.operation', 'expire');
|
|
110
|
+
span.setAttribute('db.redis.key', key);
|
|
111
|
+
span.setAttribute('db.redis.ttl', ttlSeconds);
|
|
112
|
+
await this.execute((c) => c.expire(key, ttlSeconds).then(() => undefined));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
span.recordException(error as Error);
|
|
115
|
+
throw error;
|
|
116
|
+
} finally {
|
|
117
|
+
span.end();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule as NestConfigModule } from '@nestjs/config';
|
|
3
|
+
import { plainToInstance } from 'class-transformer';
|
|
4
|
+
import { validateSync } from 'class-validator';
|
|
5
|
+
import { EnvironmentVariables } from './environment.validation';
|
|
6
|
+
|
|
7
|
+
function validate(config: Record<string, unknown>) {
|
|
8
|
+
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
|
9
|
+
enableImplicitConversion: true,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const errors = validateSync(validatedConfig, {
|
|
13
|
+
skipMissingProperties: false,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (errors.length > 0) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Environment validation failed:\n${errors
|
|
19
|
+
.map((e) => Object.values(e.constraints ?? {}).join(', '))
|
|
20
|
+
.join('\n')}`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return validatedConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Module({
|
|
28
|
+
imports: [
|
|
29
|
+
NestConfigModule.forRoot({
|
|
30
|
+
isGlobal: true,
|
|
31
|
+
validate,
|
|
32
|
+
}),
|
|
33
|
+
],
|
|
34
|
+
exports: [NestConfigModule],
|
|
35
|
+
})
|
|
36
|
+
export class AppConfigModule {}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { validateSync } from 'class-validator';
|
|
3
|
+
import { plainToInstance } from 'class-transformer';
|
|
4
|
+
import { EnvironmentVariables } from './environment.validation';
|
|
5
|
+
|
|
6
|
+
function buildConfig(overrides: Record<string, unknown> = {}): EnvironmentVariables {
|
|
7
|
+
return plainToInstance(
|
|
8
|
+
EnvironmentVariables,
|
|
9
|
+
{
|
|
10
|
+
DATABASE_URL: 'postgresql://user:pass@localhost:5432/db',
|
|
11
|
+
REDIS_URL: 'redis://localhost:6379',
|
|
12
|
+
...overrides,
|
|
13
|
+
},
|
|
14
|
+
{ enableImplicitConversion: true },
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('EnvironmentVariables', () => {
|
|
19
|
+
describe('valid configuration', () => {
|
|
20
|
+
it('should pass validation with all required fields provided', () => {
|
|
21
|
+
const config = buildConfig();
|
|
22
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
23
|
+
expect(errors).toHaveLength(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should use default PORT of 3000 when PORT is not provided', () => {
|
|
27
|
+
const config = buildConfig();
|
|
28
|
+
expect(config.PORT).toBe(3000);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should use default NODE_ENV of development when NODE_ENV is not provided', () => {
|
|
32
|
+
const config = buildConfig();
|
|
33
|
+
expect(config.NODE_ENV).toBe('development');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should accept PORT within valid range (1–65535)', () => {
|
|
37
|
+
const config = buildConfig({ PORT: '8080' });
|
|
38
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
39
|
+
expect(errors).toHaveLength(0);
|
|
40
|
+
expect(config.PORT).toBe(8080);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should accept all valid NODE_ENV values', () => {
|
|
44
|
+
for (const env of ['development', 'production', 'test']) {
|
|
45
|
+
const config = buildConfig({ NODE_ENV: env });
|
|
46
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
47
|
+
expect(errors).toHaveLength(0);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('missing required fields', () => {
|
|
53
|
+
it('should fail validation when DATABASE_URL is missing', () => {
|
|
54
|
+
const config = plainToInstance(
|
|
55
|
+
EnvironmentVariables,
|
|
56
|
+
{ REDIS_URL: 'redis://localhost:6379' },
|
|
57
|
+
{ enableImplicitConversion: true },
|
|
58
|
+
);
|
|
59
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
60
|
+
const fieldNames = errors.map((e) => e.property);
|
|
61
|
+
expect(fieldNames).toContain('DATABASE_URL');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should fail validation when REDIS_URL is missing', () => {
|
|
65
|
+
const config = plainToInstance(
|
|
66
|
+
EnvironmentVariables,
|
|
67
|
+
{ DATABASE_URL: 'postgresql://user:pass@localhost:5432/db' },
|
|
68
|
+
{ enableImplicitConversion: true },
|
|
69
|
+
);
|
|
70
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
71
|
+
const fieldNames = errors.map((e) => e.property);
|
|
72
|
+
expect(fieldNames).toContain('REDIS_URL');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('PORT validation', () => {
|
|
77
|
+
it('should fail validation when PORT is below 1', () => {
|
|
78
|
+
const config = buildConfig({ PORT: '0' });
|
|
79
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
80
|
+
const portErrors = errors.filter((e) => e.property === 'PORT');
|
|
81
|
+
expect(portErrors.length).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should fail validation when PORT exceeds 65535', () => {
|
|
85
|
+
const config = buildConfig({ PORT: '65536' });
|
|
86
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
87
|
+
const portErrors = errors.filter((e) => e.property === 'PORT');
|
|
88
|
+
expect(portErrors.length).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('NODE_ENV whitelist', () => {
|
|
93
|
+
it('should fail validation when NODE_ENV is not in the allowed list', () => {
|
|
94
|
+
const config = buildConfig({ NODE_ENV: 'staging' });
|
|
95
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
96
|
+
const nodeEnvErrors = errors.filter((e) => e.property === 'NODE_ENV');
|
|
97
|
+
expect(nodeEnvErrors.length).toBeGreaterThan(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { IsString, IsInt, IsIn, IsOptional, Min, Max } from 'class-validator';
|
|
2
|
+
import { Transform } from 'class-transformer';
|
|
3
|
+
|
|
4
|
+
export class EnvironmentVariables {
|
|
5
|
+
@IsString()
|
|
6
|
+
DATABASE_URL: string;
|
|
7
|
+
|
|
8
|
+
@IsString()
|
|
9
|
+
REDIS_URL: string;
|
|
10
|
+
|
|
11
|
+
@IsOptional()
|
|
12
|
+
@Transform(({ value }) => parseInt(value, 10))
|
|
13
|
+
@IsInt()
|
|
14
|
+
@Min(1)
|
|
15
|
+
@Max(65535)
|
|
16
|
+
PORT: number = 3000;
|
|
17
|
+
|
|
18
|
+
@IsOptional()
|
|
19
|
+
@IsIn(['development', 'production', 'test'])
|
|
20
|
+
NODE_ENV: string = 'development';
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
|
2
|
+
import { PrismaClient } from '@prisma/client';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
|
6
|
+
private readonly logger = new Logger(PrismaService.name);
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
super();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async onModuleInit(): Promise<void> {
|
|
13
|
+
await this.$connect();
|
|
14
|
+
this.logger.log('Prisma connected to database');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async onModuleDestroy(): Promise<void> {
|
|
18
|
+
await this.$disconnect();
|
|
19
|
+
this.logger.log('Prisma disconnected from database');
|
|
20
|
+
}
|
|
21
|
+
}
|