@tsed/mikro-orm 8.0.1 → 8.0.3

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.
@@ -0,0 +1,19 @@
1
+ import {OptimisticLockError} from "@mikro-orm/core";
2
+ import {Catch, type ExceptionFilterMethods} from "@tsed/platform-exceptions";
3
+ import {PlatformContext} from "@tsed/platform-http";
4
+
5
+ @Catch(OptimisticLockError)
6
+ export class OptimisticLockErrorFilter implements ExceptionFilterMethods<OptimisticLockError> {
7
+ public catch(exception: OptimisticLockError, ctx: PlatformContext): void {
8
+ const {response, logger} = ctx;
9
+
10
+ logger.error({
11
+ event: "MIKRO_ORM_OPTIMISTIC_LOCK_ERROR",
12
+ error_name: exception.name,
13
+ error_message: exception.message,
14
+ stack: exception.stack
15
+ });
16
+
17
+ response.status(409).body(`Update refused. The resource has changed on the server. Please try again later`);
18
+ }
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @file Automatically generated by @tsed/barrels.
3
+ */
4
+ export * from "./constants.js";
5
+ export * from "./decorators/entityManager.js";
6
+ export * from "./decorators/orm.js";
7
+ export * from "./decorators/subscriber.js";
8
+ export * from "./decorators/transactional.js";
9
+ export * from "./filters/OptimisticLockErrorFilter.js";
10
+ export * from "./interceptors/TransactionalInterceptor.js";
11
+ export * from "./interfaces/AfterTransactionCommit.js";
12
+ export * from "./interfaces/BeforeTransactionCommit.js";
13
+ export * from "./interfaces/RetryStrategy.js";
14
+ export * from "./MikroOrmModule.js";
15
+ export * from "./services/MikroOrmContext.js";
16
+ export * from "./services/MikroOrmFactory.js";
17
+ export * from "./services/MikroOrmRegistry.js";
@@ -0,0 +1,248 @@
1
+ import {EntityManager, IsolationLevel, MikroORM, OptimisticLockError} from "@mikro-orm/core";
2
+ import {InjectorService, InterceptorContext} from "@tsed/di";
3
+ import {Logger} from "@tsed/logger";
4
+ import {anyFunction, anything, deepEqual, instance, mock, objectContaining, reset, verify, when} from "ts-mockito";
5
+
6
+ import {RetryStrategy} from "../interfaces/RetryStrategy.js";
7
+ import {MikroOrmContext} from "../services/MikroOrmContext.js";
8
+ import {MikroOrmRegistry} from "../services/MikroOrmRegistry.js";
9
+ import {TransactionalInterceptor} from "./TransactionalInterceptor.js";
10
+
11
+ // AHDOC: https://github.com/NagRock/ts-mockito/issues/191
12
+ // TODO: avoid using ts-mockito
13
+ const resolvableInstance = <T extends object>(m: T): T =>
14
+ new Proxy<T>(instance(m), {
15
+ get(target, prop, receiver) {
16
+ if (["Symbol(Symbol.toPrimitive)", "then", "catch"].includes(prop.toString())) {
17
+ return undefined;
18
+ }
19
+
20
+ return Reflect.get(target, prop, receiver);
21
+ }
22
+ });
23
+
24
+ describe("TransactionalInterceptor", () => {
25
+ const mockedInjectorService = mock<InjectorService>();
26
+ const mockedMikroOrmRegistry = mock<MikroOrmRegistry>();
27
+ const mockedMikroOrm = mock<MikroORM>();
28
+ const mockedEntityManager = mock<EntityManager>();
29
+ const mockedLogger = mock<Logger>();
30
+ const mockedRetryStrategy = mock<RetryStrategy>();
31
+ const mockedMikroOrmContext = mock<MikroOrmContext>();
32
+ const next = vi.fn();
33
+
34
+ let entityManger!: EntityManager;
35
+ let mikroOrm!: MikroORM;
36
+ let transactionalInterceptor!: TransactionalInterceptor;
37
+
38
+ afterEach(() => {
39
+ next.mockReset();
40
+ reset<InjectorService | MikroOrmRegistry | MikroORM | EntityManager | Logger | RetryStrategy | MikroOrmContext>(
41
+ mockedInjectorService,
42
+ mockedMikroOrmRegistry,
43
+ mockedMikroOrm,
44
+ mockedEntityManager,
45
+ mockedLogger,
46
+ mockedMikroOrmContext,
47
+ mockedRetryStrategy
48
+ );
49
+ });
50
+ beforeEach(() => {
51
+ entityManger = resolvableInstance(mockedEntityManager);
52
+ mikroOrm = resolvableInstance(mockedMikroOrm);
53
+ when(mockedMikroOrm.em).thenReturn(entityManger);
54
+
55
+ transactionalInterceptor = new TransactionalInterceptor(
56
+ instance(mockedInjectorService),
57
+ instance(mockedMikroOrmRegistry),
58
+ instance(mockedMikroOrmContext),
59
+ instance(mockedLogger)
60
+ );
61
+ });
62
+
63
+ describe("intercept", () => {
64
+ it("should run within a existing context", async () => {
65
+ // arrange
66
+ const context = {} as InterceptorContext;
67
+
68
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(mikroOrm);
69
+ when(mockedMikroOrmContext.has(anything())).thenReturn(true);
70
+ when(mockedMikroOrmContext.get(anything())).thenReturn(entityManger);
71
+
72
+ // act
73
+ await transactionalInterceptor.intercept(context, next);
74
+
75
+ // assert
76
+ verify(mockedEntityManager.transactional(anyFunction(), objectContaining({}))).once();
77
+ });
78
+
79
+ it("should set an isolation level", async () => {
80
+ // arrange
81
+ const context = {options: {isolationLevel: IsolationLevel.SERIALIZABLE}} as InterceptorContext;
82
+
83
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(mikroOrm);
84
+ when(mockedMikroOrmContext.has(anything())).thenReturn(true);
85
+ when(mockedMikroOrmContext.get(anything())).thenReturn(entityManger);
86
+
87
+ // act
88
+ await transactionalInterceptor.intercept(context, next);
89
+
90
+ // assert
91
+ verify(mockedEntityManager.transactional(anyFunction(), objectContaining({isolationLevel: IsolationLevel.SERIALIZABLE}))).once();
92
+ });
93
+
94
+ it("should run within a new context", async () => {
95
+ // arrange
96
+ const context = {} as InterceptorContext;
97
+
98
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(mikroOrm);
99
+ when(mockedMikroOrmContext.has(anything())).thenReturn(false);
100
+ when(mockedMikroOrmContext.get(anything())).thenReturn(entityManger);
101
+ when(mockedMikroOrmContext.run(anything(), anything())).thenCall((_: EntityManager[], func: (...args: unknown[]) => unknown) =>
102
+ func()
103
+ );
104
+
105
+ // act
106
+ await transactionalInterceptor.intercept(context, next);
107
+
108
+ // assert
109
+ verify(mockedMikroOrmContext.run(deepEqual([entityManger]), anyFunction())).twice();
110
+ verify(mockedEntityManager.transactional(anyFunction(), objectContaining({}))).once();
111
+ });
112
+
113
+ it("should perform a task within a transaction", async () => {
114
+ // arrange
115
+ const context = {} as InterceptorContext;
116
+
117
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(mikroOrm);
118
+ when(mockedMikroOrmContext.has(anything())).thenReturn(true);
119
+ when(mockedMikroOrmContext.get(anything())).thenReturn(entityManger);
120
+ when(mockedEntityManager.transactional(anything(), anything())).thenCall((func: (...args: unknown[]) => unknown) => func());
121
+
122
+ // act
123
+ await transactionalInterceptor.intercept(context, next);
124
+
125
+ // assert
126
+ expect(next).toHaveBeenCalled();
127
+ });
128
+
129
+ it("should emit events in the right order", async () => {
130
+ // arrange
131
+ const context = {} as InterceptorContext;
132
+
133
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(mikroOrm);
134
+ when(mockedMikroOrmContext.has(anything())).thenReturn(true);
135
+ when(mockedMikroOrmContext.get(anything())).thenReturn(entityManger);
136
+ when(mockedEntityManager.transactional(anything(), anything())).thenCall((func: (...args: unknown[]) => unknown) => func());
137
+ when(mockedMikroOrmContext.run(anything(), anything())).thenCall((_: EntityManager[], func: (...args: unknown[]) => unknown) =>
138
+ func()
139
+ );
140
+
141
+ // act
142
+ await transactionalInterceptor.intercept(context, next);
143
+
144
+ // assert
145
+ verify(mockedInjectorService.alterAsync("$beforeTransactionCommit", entityManger)).calledBefore(
146
+ mockedInjectorService.alterAsync("$afterTransactionCommit", entityManger)
147
+ );
148
+ });
149
+
150
+ it("should disable an explicit transaction", async () => {
151
+ // arrange
152
+ const context = {options: {disabled: true}} as InterceptorContext;
153
+
154
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(mikroOrm);
155
+ when(mockedMikroOrmContext.has(anything())).thenReturn(true);
156
+ when(mockedMikroOrmContext.get(anything())).thenReturn(entityManger);
157
+
158
+ // act
159
+ await transactionalInterceptor.intercept(context, next);
160
+
161
+ // assert
162
+ expect(next).toHaveBeenCalled();
163
+ verify(mockedEntityManager.transactional(anything(), anything())).never();
164
+ });
165
+
166
+ it("should throw an error if context is lost", async () => {
167
+ // arrange
168
+ const context = {} as InterceptorContext;
169
+
170
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(mikroOrm);
171
+ when(mockedMikroOrmContext.has(anything())).thenReturn(true);
172
+ when(mockedEntityManager.transactional(anything(), anything())).thenCall((func: (...args: unknown[]) => unknown) => func());
173
+
174
+ // act
175
+ const result = transactionalInterceptor.intercept(context, next);
176
+
177
+ // assert
178
+ await expect(result).rejects.toThrow("No such context");
179
+ });
180
+
181
+ it("should throw an error if no such context", async () => {
182
+ // arrange
183
+ const context = {} as InterceptorContext;
184
+
185
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn();
186
+
187
+ // act
188
+ const result = transactionalInterceptor.intercept(context, next);
189
+
190
+ // assert
191
+ await expect(result).rejects.toThrow("No such context");
192
+ });
193
+
194
+ it("should throw an optimistic lock exception immediately if no retry strategy", async () => {
195
+ // arrange
196
+ const context = {} as InterceptorContext;
197
+
198
+ when(mockedMikroOrmContext.has(anything())).thenReturn(true);
199
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(mikroOrm);
200
+ when(mockedEntityManager.transactional(anything(), anything())).thenReject(OptimisticLockError.lockFailed("Lock"));
201
+ when(mockedMikroOrmContext.get(anything())).thenReturn(entityManger);
202
+
203
+ // act
204
+ const result = transactionalInterceptor.intercept(context, next);
205
+
206
+ // assert
207
+ await expect(result).rejects.toThrow("Lock");
208
+ verify(mockedEntityManager.transactional(anyFunction(), objectContaining({}))).times(1);
209
+ });
210
+
211
+ it("should apply a retry mechanism if retry is enabled", async () => {
212
+ // arrange
213
+ transactionalInterceptor = new TransactionalInterceptor(
214
+ instance(mockedInjectorService),
215
+ instance(mockedMikroOrmRegistry),
216
+ instance(mockedMikroOrmContext),
217
+ instance(mockedLogger),
218
+ instance(mockedRetryStrategy)
219
+ );
220
+ const context = {options: {retry: true}} as InterceptorContext;
221
+
222
+ when(mockedMikroOrmContext.has(anything())).thenReturn(true);
223
+ when(mockedRetryStrategy.acquire(anything())).thenResolve();
224
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(mikroOrm);
225
+ when(mockedEntityManager.transactional(anything(), anything())).thenReject(OptimisticLockError.lockFailed("Lock"));
226
+ when(mockedMikroOrmContext.get(anything())).thenReturn(entityManger);
227
+
228
+ // act
229
+ await transactionalInterceptor.intercept(context, next);
230
+
231
+ // assert
232
+ verify(mockedRetryStrategy.acquire(anything())).once();
233
+ });
234
+
235
+ it("should log a warning if the retry is enabled, while a retry strategy is not resolved", async () => {
236
+ // arrange
237
+ const context = {options: {retry: true}} as InterceptorContext;
238
+
239
+ when(mockedMikroOrmRegistry.get(anything())).thenReturn(instance(mockedMikroOrm));
240
+
241
+ // act
242
+ await transactionalInterceptor.intercept(context, next);
243
+
244
+ // assert
245
+ verify(mockedLogger.warn(`To retry a transaction you have to implement a "${RetryStrategy.description}" interface`)).once();
246
+ });
247
+ });
248
+ });
@@ -0,0 +1,130 @@
1
+ import {EntityManager, FlushMode, IsolationLevel} from "@mikro-orm/core";
2
+ import {Inject, InjectorService, Interceptor, InterceptorContext, InterceptorMethods, InterceptorNext} from "@tsed/di";
3
+ import {Logger} from "@tsed/logger";
4
+
5
+ import {DEFAULT_CONTEXT_NAME} from "../constants.js";
6
+ import {RetryStrategy} from "../interfaces/RetryStrategy.js";
7
+ import {MikroOrmContext} from "../services/MikroOrmContext.js";
8
+ import {MikroOrmRegistry} from "../services/MikroOrmRegistry.js";
9
+
10
+ export interface TransactionOptions {
11
+ retry?: boolean;
12
+ disabled?: boolean;
13
+ isolationLevel?: IsolationLevel;
14
+ flushMode?: FlushMode;
15
+ contextName?: string;
16
+ }
17
+
18
+ type TransactionSettings = Omit<TransactionOptions, "contextName"> & Required<Pick<TransactionOptions, "contextName">>;
19
+
20
+ @Interceptor()
21
+ export class TransactionalInterceptor implements InterceptorMethods {
22
+ constructor(
23
+ @Inject() private readonly injector: InjectorService,
24
+ @Inject() private readonly registry: MikroOrmRegistry,
25
+ @Inject() private readonly context: MikroOrmContext,
26
+ @Inject() private readonly logger: Logger,
27
+ @Inject(RetryStrategy)
28
+ private readonly retryStrategy?: RetryStrategy
29
+ ) {}
30
+
31
+ public intercept(context: InterceptorContext<unknown>, next: InterceptorNext) {
32
+ const options = this.extractContextName(context.options);
33
+
34
+ if (options.retry && !this.retryStrategy) {
35
+ this.logger.warn(`To retry a transaction you have to implement a "${RetryStrategy.description}" interface`);
36
+ }
37
+
38
+ return this.context.has(options.contextName) ? this.runWithinCtx(next, options) : this.runWithinNewCtx(next, options);
39
+ }
40
+
41
+ private runWithinCtx(next: InterceptorNext, options: TransactionSettings): Promise<unknown> {
42
+ const callback = () => this.executeInTransaction(next, options);
43
+
44
+ return options.retry && this.retryStrategy ? this.retryStrategy.acquire(callback) : callback();
45
+ }
46
+
47
+ private async runWithinNewCtx(next: InterceptorNext, options: TransactionSettings): Promise<unknown> {
48
+ const em = await this.getGlobalEntityManager(options.contextName);
49
+
50
+ return this.context.run([em], () => this.runWithinCtx(next, options));
51
+ }
52
+
53
+ private getGlobalEntityManager(contextName: string = DEFAULT_CONTEXT_NAME): EntityManager {
54
+ const orm = this.registry.get(contextName);
55
+
56
+ if (!orm) {
57
+ throw new Error(
58
+ `No such context: ${contextName}. Please register a corresponding MikroOrm instance using '@Configuration()' decorator.`
59
+ );
60
+ }
61
+
62
+ return orm.em;
63
+ }
64
+
65
+ private extractContextName(options: TransactionOptions | string | undefined): TransactionSettings {
66
+ if (typeof options === "string") {
67
+ return {
68
+ contextName: options
69
+ };
70
+ }
71
+
72
+ const {flushMode, isolationLevel, contextName = DEFAULT_CONTEXT_NAME, retry = false, disabled = false} = options ?? {};
73
+
74
+ return {
75
+ contextName,
76
+ retry,
77
+ disabled,
78
+ flushMode,
79
+ isolationLevel
80
+ };
81
+ }
82
+
83
+ private async executeInTransaction(next: InterceptorNext, options: TransactionSettings): Promise<unknown> {
84
+ const em = this.getEntityManager(options.contextName);
85
+
86
+ if (options.disabled) {
87
+ return next();
88
+ }
89
+
90
+ const result = await em.transactional(() => this.executeHandlerDelegate(next, options), {
91
+ flushMode: options.flushMode,
92
+ isolationLevel: options.isolationLevel
93
+ });
94
+
95
+ await this.afterTransactionCommit(options);
96
+
97
+ return result;
98
+ }
99
+
100
+ private async executeHandlerDelegate(next: InterceptorNext, options: TransactionSettings): Promise<unknown> {
101
+ const result = await next();
102
+ await this.beforeTransactionCommit(options);
103
+ return result;
104
+ }
105
+
106
+ private beforeTransactionCommit(options: TransactionSettings): Promise<unknown> {
107
+ return this.injector.alterAsync("$beforeTransactionCommit", this.getEntityManager(options.contextName));
108
+ }
109
+
110
+ private afterTransactionCommit(options: TransactionSettings): Promise<unknown> {
111
+ const em = this.getGlobalEntityManager(options.contextName);
112
+
113
+ return this.context.run([em], () => {
114
+ const forkedEm = this.getEntityManager(options.contextName);
115
+ forkedEm.resetTransactionContext();
116
+
117
+ return this.injector.alterAsync("$afterTransactionCommit", forkedEm);
118
+ });
119
+ }
120
+
121
+ private getEntityManager(contextName: string = DEFAULT_CONTEXT_NAME): EntityManager {
122
+ const em = this.context.get(contextName);
123
+
124
+ if (!em) {
125
+ throw new Error(`No such context: ${contextName}. Please check if the async context is lost in one of the asynchronous operations.`);
126
+ }
127
+
128
+ return em;
129
+ }
130
+ }
@@ -0,0 +1,5 @@
1
+ import {EntityManager} from "@mikro-orm/core";
2
+
3
+ export interface AfterTransactionCommit {
4
+ $afterTransactionCommit(em: EntityManager): Promise<unknown> | unknown;
5
+ }
@@ -0,0 +1,5 @@
1
+ import {EntityManager} from "@mikro-orm/core";
2
+
3
+ export interface BeforeTransactionCommit {
4
+ $beforeTransactionCommit(em: EntityManager): Promise<unknown> | unknown;
5
+ }
@@ -0,0 +1,5 @@
1
+ export interface RetryStrategy {
2
+ acquire<T extends (...args: unknown[]) => unknown>(task: T): Promise<ReturnType<T> extends Promise<infer V> ? V : ReturnType<T>>;
3
+ }
4
+
5
+ export const RetryStrategy: unique symbol = Symbol("RetryStrategy");
@@ -0,0 +1,95 @@
1
+ import {EntityManager, RequestContext} from "@mikro-orm/core";
2
+ import {deepEqual, instance as originalInstance, mock, reset, spy, verify, when} from "ts-mockito";
3
+
4
+ import {MikroOrmContext} from "./MikroOrmContext.js";
5
+
6
+ const instance = <T extends object>(m: T): T =>
7
+ new Proxy<T>(originalInstance(m), {
8
+ get(target, prop, receiver) {
9
+ if (["Symbol(Symbol.toPrimitive)", "Symbol(Symbol.isConcatSpreadable)", "length"].includes(prop.toString())) {
10
+ return undefined;
11
+ }
12
+ return Reflect.get(target, prop, receiver);
13
+ }
14
+ });
15
+
16
+ describe("MikroOrmContext", () => {
17
+ const mockedEntityManager = mock<EntityManager>();
18
+
19
+ let spiedRequestContext: typeof RequestContext;
20
+ let entityManager!: EntityManager;
21
+ let context!: MikroOrmContext;
22
+
23
+ beforeEach(() => {
24
+ spiedRequestContext = spy(RequestContext);
25
+ entityManager = instance(mockedEntityManager);
26
+ context = new MikroOrmContext();
27
+ });
28
+ afterEach(() => reset<typeof RequestContext | EntityManager>(mockedEntityManager, spiedRequestContext));
29
+
30
+ describe("get", () => {
31
+ it("should return undefined if not such context", () => {
32
+ // act
33
+ const result = context.get("unknown");
34
+
35
+ // assert
36
+ expect(result).toBeUndefined();
37
+ });
38
+
39
+ it("should return entity manager if context is initialized", () => {
40
+ // arrange
41
+ when(spiedRequestContext.getEntityManager("context1")).thenReturn(entityManager);
42
+
43
+ // act
44
+ const result = context.get("context1");
45
+
46
+ // assert
47
+ expect(result).toBe(entityManager);
48
+ });
49
+ });
50
+
51
+ describe("wrap", () => {
52
+ it("should run a function within a mikro-orm context", async () => {
53
+ // arrange
54
+ const task = vi.fn();
55
+
56
+ // act
57
+ await context.run([entityManager], task);
58
+
59
+ // assert
60
+ verify(spiedRequestContext.create(deepEqual([entityManager]), task)).once();
61
+ });
62
+
63
+ it("should return a result of function execution", async () => {
64
+ // arrange
65
+ const task = vi.fn().mockResolvedValue("string");
66
+
67
+ // act
68
+ const result = await context.run([entityManager], task);
69
+
70
+ // assert
71
+ expect(result).toEqual("string");
72
+ });
73
+ });
74
+
75
+ describe("has", () => {
76
+ it("should return false if not such context", () => {
77
+ // act
78
+ const result = context.has("unknown");
79
+
80
+ // assert
81
+ expect(result).toBeFalsy();
82
+ });
83
+
84
+ it("should return true if context is initialized", () => {
85
+ // arrange
86
+ when(spiedRequestContext.getEntityManager("context1")).thenReturn(entityManager);
87
+
88
+ // act
89
+ const result = context.has("context1");
90
+
91
+ // assert
92
+ expect(result).toBe(true);
93
+ });
94
+ });
95
+ });
@@ -0,0 +1,29 @@
1
+ import {EntityManager, RequestContext} from "@mikro-orm/core";
2
+ import {isFunction} from "@tsed/core";
3
+ import {Injectable} from "@tsed/di";
4
+
5
+ /**
6
+ * @internal Since 2022-06-20.
7
+ */
8
+ @Injectable()
9
+ export class MikroOrmContext {
10
+ public run(managers: EntityManager[], task: (...args: unknown[]) => unknown): Promise<unknown> {
11
+ // @ts-expect-error `RequestContext.createAsync` has been removed in favour of `RequestContext.create` since mikro-orm v6.0.0
12
+ if (isFunction(RequestContext.createAsync)) {
13
+ // @ts-expect-error see above
14
+ return RequestContext.createAsync(managers, task);
15
+ } else {
16
+ return RequestContext.create(managers, task) as Promise<unknown>;
17
+ }
18
+ }
19
+
20
+ public get(contextName?: string): EntityManager | undefined {
21
+ return RequestContext.getEntityManager(contextName);
22
+ }
23
+
24
+ public has(contextName?: string): boolean {
25
+ const manager = this.get(contextName);
26
+
27
+ return !!manager;
28
+ }
29
+ }
@@ -0,0 +1,16 @@
1
+ import {MikroORM, Options} from "@mikro-orm/core";
2
+ import {Inject, Injectable} from "@tsed/di";
3
+
4
+ import {MikroOrmContext} from "./MikroOrmContext.js";
5
+
6
+ @Injectable()
7
+ export class MikroOrmFactory {
8
+ constructor(@Inject() private readonly context: MikroOrmContext) {}
9
+
10
+ public create(options: Options): Promise<MikroORM> {
11
+ return MikroORM.init({
12
+ ...options,
13
+ context: (name: string) => this.context.get(name)
14
+ });
15
+ }
16
+ }