sonamu 0.10.8 → 0.10.10

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.
@@ -99,11 +99,8 @@ export class PuriWrapper<TSchema extends DatabaseSchemaExtend = DatabaseSchemaEx
99
99
  ): Promise<T> {
100
100
  const { isolation, readOnly, dbPreset = "w" } = options;
101
101
 
102
- // @transactional 데코레이터와 동일한 로직: 이미 트랜잭션 컨텍스트가 있는지 확인
103
102
  const { DB } = await import("./db");
104
- const existingContext = DB.transactionStorage.getStore();
105
103
 
106
- // AsyncLocalStorage 컨텍스트가 없거나 해당 preset의 트랜잭션이 없으면 새로 시작
107
104
  const startTransaction = async (
108
105
  knex: Knex | Knex.Transaction,
109
106
  upsertBuilder: UpsertBuilder,
@@ -112,33 +109,19 @@ export class PuriWrapper<TSchema extends DatabaseSchemaExtend = DatabaseSchemaEx
112
109
  async (trx) => {
113
110
  const trxWrapper = new PuriTransactionWrapper(trx, upsertBuilder);
114
111
 
115
- // TransactionContext에 트랜잭션 저장
116
- DB.getTransactionContext().setTransaction(dbPreset, trxWrapper);
117
-
118
- try {
119
- return await callback(trxWrapper);
120
- } finally {
121
- // 트랜잭션 제거
122
- DB.getTransactionContext().deleteTransaction(dbPreset);
123
- }
112
+ return DB.runWithTransactionScope(dbPreset, trxWrapper, () => callback(trxWrapper));
124
113
  },
125
114
  { isolationLevel: isolation, readOnly },
126
115
  );
127
116
  };
128
117
 
129
- // AsyncLocalStorage 컨텍스트가 없으면 새로 생성
130
- if (!existingContext) {
131
- return DB.runWithTransaction(() => startTransaction(this.knex, this.upsertBuilder));
132
- }
133
-
134
- // 해당 preset의 트랜잭션이 이미 있으면 SAVEPOINT로 중첩 트랜잭션 생성
135
- const existingTrx = existingContext.getTransaction(dbPreset);
118
+ const existingTrx = DB.transactionStorage.getStore()?.getTransaction(dbPreset);
136
119
  if (existingTrx) {
120
+ // 같은 preset의 중첩 트랜잭션은 기존 연결에서 SAVEPOINT로 시작한다.
137
121
  return startTransaction(existingTrx.trx, existingTrx.upsertBuilder);
138
- } else {
139
- // 컨텍스트는 있지만 이 preset의 트랜잭션은 없는 경우 (같은 컨텍스트 내에서 실행)
140
- return startTransaction(this.knex, this.upsertBuilder);
141
122
  }
123
+
124
+ return startTransaction(this.knex, this.upsertBuilder);
142
125
  }
143
126
 
144
127
  ubRegister<TTable extends TableName<TSchema>>(
@@ -2,17 +2,25 @@ import { type DBPreset } from "./db";
2
2
  import { type PuriTransactionWrapper } from "./puri-wrapper";
3
3
 
4
4
  export class TransactionContext {
5
- private transactions: Map<DBPreset, PuriTransactionWrapper> = new Map();
5
+ constructor(
6
+ private readonly parent?: TransactionContext,
7
+ private local?: {
8
+ preset: DBPreset;
9
+ transaction: PuriTransactionWrapper;
10
+ },
11
+ ) {}
6
12
 
7
13
  getTransaction(preset: DBPreset): PuriTransactionWrapper | undefined {
8
- return this.transactions.get(preset);
14
+ return this.local?.preset === preset
15
+ ? this.local.transaction
16
+ : this.parent?.getTransaction(preset);
9
17
  }
10
18
 
11
- setTransaction(preset: DBPreset, trx: PuriTransactionWrapper): void {
12
- this.transactions.set(preset, trx);
19
+ getActiveTransaction(): PuriTransactionWrapper | undefined {
20
+ return this.local?.transaction ?? this.parent?.getActiveTransaction();
13
21
  }
14
22
 
15
- deleteTransaction(preset: DBPreset): void {
16
- this.transactions.delete(preset);
23
+ clearLocal(): void {
24
+ this.local = undefined;
17
25
  }
18
26
  }
@@ -6,6 +6,11 @@
6
6
  *
7
7
  * 프로젝트별로 고유한 소켓을 사용하기 위해 sonamu.config.ts 경로의 해시를 사용합니다.
8
8
  *
9
+ * 모듈 싱글턴이지만 인스턴스는 프로세스마다 별개입니다. run/start·run/end는
10
+ * Vitest 메인 프로세스(NaiteVitestReporter 커스텀 리포터)에서, test/result는
11
+ * 각 worker 프로세스(bootstrap의 afterEach)에서 전송되므로 buffer와 소켓
12
+ * 연결을 서로 공유하지 않습니다.
13
+ *
9
14
  * fs mock 충돌을 피하기 위해 net 모듈만 사용합니다.
10
15
  */
11
16
  /* oxlint-disable @typescript-eslint/no-explicit-any */ // Naite는 expect와 호응하도록 any를 허용함
@@ -112,7 +117,7 @@ class NaiteReporterClass {
112
117
  }
113
118
 
114
119
  /**
115
- * beforeAll에서 호출합니다.
120
+ * Vitest 커스텀 리포터(NaiteVitestReporter)의 onTestRunStart에서 호출합니다.
116
121
  * 테스트 run 시작을 알립니다 (데이터 클리어 신호).
117
122
  */
118
123
  async startTestRun(): Promise<void> {
@@ -127,7 +132,7 @@ class NaiteReporterClass {
127
132
  }
128
133
 
129
134
  /**
130
- * afterEach에서 호출합니다.
135
+ * bootstrap()의 afterEach에서 호출합니다.
131
136
  * 테스트 케이스 결과를 traces와 함께 전송합니다.
132
137
  */
133
138
  async reportTestResult(
@@ -145,7 +150,7 @@ class NaiteReporterClass {
145
150
  }
146
151
 
147
152
  /**
148
- * afterAll에서 호출합니다.
153
+ * Vitest 커스텀 리포터(NaiteVitestReporter)의 onTestRunEnd에서 호출합니다.
149
154
  * 테스트 run 종료를 알립니다.
150
155
  */
151
156
  async endTestRun(): Promise<void> {
@@ -0,0 +1,169 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+
3
+ import { Sonamu } from "../../api";
4
+ import { type ExtendedApi } from "../../api/decorators";
5
+ import { Template__services } from "../implementations/services.template";
6
+
7
+ describe("Template__services mutation hooks", () => {
8
+ let originalConfig: unknown;
9
+ let originalSyncer: unknown;
10
+
11
+ beforeEach(() => {
12
+ originalConfig = Reflect.get(Sonamu, "_config");
13
+ originalSyncer = Reflect.get(Sonamu, "_syncer");
14
+ Reflect.set(Sonamu, "_config", {
15
+ api: {
16
+ route: {
17
+ prefix: "/api",
18
+ },
19
+ },
20
+ });
21
+ });
22
+
23
+ afterEach(() => {
24
+ Reflect.set(Sonamu, "_config", originalConfig);
25
+ Reflect.set(Sonamu, "_syncer", originalSyncer);
26
+ });
27
+
28
+ const renderServices = (apis: ExtendedApi[]) => {
29
+ Reflect.set(Sonamu, "_syncer", { apis });
30
+ return new Template__services().render({}).body;
31
+ };
32
+
33
+ it("파라미터가 있는 일반 mutation에서 namespace와 인자 순서를 유지한다", () => {
34
+ const body = renderServices([
35
+ {
36
+ modelName: "CaseModel",
37
+ methodName: "leave",
38
+ path: "/case/leave",
39
+ options: {
40
+ httpMethod: "POST",
41
+ clients: ["tanstack-mutation"],
42
+ },
43
+ typeParameters: [],
44
+ parameters: [
45
+ { name: "caseId", type: "number", optional: false },
46
+ { name: "notify", type: "boolean", optional: false },
47
+ ],
48
+ returnType: {
49
+ t: "ref",
50
+ id: "Promise",
51
+ args: ["boolean"],
52
+ },
53
+ },
54
+ ]);
55
+
56
+ expect(body).toContain(`export const useLeaveMutation = () => useMutation({
57
+ mutationFn: (params: { caseId: number, notify: boolean }) => CaseService.leave(params.caseId, params.notify)
58
+ });`);
59
+ expect(body).not.toContain(
60
+ "mutationFn: (params: { caseId: number, notify: boolean }) => leave(params.caseId, params.notify)",
61
+ );
62
+ });
63
+
64
+ it("파라미터가 없는 일반 mutation에서 params: void 타입을 유지한다", () => {
65
+ const body = renderServices([
66
+ {
67
+ modelName: "CaseModel",
68
+ methodName: "refresh",
69
+ path: "/case/refresh",
70
+ options: {
71
+ httpMethod: "POST",
72
+ clients: ["tanstack-mutation"],
73
+ },
74
+ typeParameters: [],
75
+ parameters: [],
76
+ returnType: {
77
+ t: "ref",
78
+ id: "Promise",
79
+ args: ["void"],
80
+ },
81
+ },
82
+ ]);
83
+
84
+ expect(body).toContain(`export const useRefreshMutation = () => useMutation({
85
+ mutationFn: (params: void) => CaseService.refresh()
86
+ });`);
87
+ expect(body).not.toContain("mutationFn: (params: void) => refresh()");
88
+ });
89
+
90
+ it("파일만 받는 multipart mutation에서 namespace와 mutation 옵션을 유지한다", () => {
91
+ const body = renderServices([
92
+ {
93
+ modelName: "VoiceFrame",
94
+ methodName: "uploadRecording",
95
+ path: "/voice/uploadRecording",
96
+ options: {
97
+ httpMethod: "POST",
98
+ clients: ["axios-multipart", "tanstack-mutation-multipart"],
99
+ },
100
+ typeParameters: [],
101
+ parameters: [],
102
+ returnType: {
103
+ t: "ref",
104
+ id: "Promise",
105
+ args: ["string"],
106
+ },
107
+ },
108
+ ]);
109
+
110
+ expect(body).toContain(`export const useUploadRecordingMutation = (
111
+ options?: UseMutationOptions<string, Error, { files: File[] }> & {
112
+ onUploadProgress?: (e: AxiosProgressEvent) => void;
113
+ }
114
+ ) => useMutation({
115
+ mutationFn: (params: { files: File[] }) => VoiceService.uploadRecording(params.files),
116
+ retry: false,
117
+ ...options,
118
+ });`);
119
+ expect(body).not.toContain(
120
+ "mutationFn: (params: { files: File[] }) => uploadRecording(params.files)",
121
+ );
122
+ });
123
+
124
+ it("API 파라미터와 파일을 받는 multipart mutation에서 namespace와 인자 순서를 유지한다", () => {
125
+ const body = renderServices([
126
+ {
127
+ modelName: "VoiceFrame",
128
+ methodName: "transcript",
129
+ path: "/voice/transcript",
130
+ options: {
131
+ httpMethod: "POST",
132
+ clients: ["axios-multipart", "tanstack-mutation-multipart"],
133
+ },
134
+ typeParameters: [],
135
+ parameters: [
136
+ {
137
+ name: "params",
138
+ type: {
139
+ t: "object",
140
+ props: [
141
+ { name: "language", type: "string", optional: false },
142
+ { name: "speakerCount", type: "number", optional: false },
143
+ ],
144
+ },
145
+ optional: false,
146
+ },
147
+ ],
148
+ returnType: {
149
+ t: "ref",
150
+ id: "Promise",
151
+ args: ["number"],
152
+ },
153
+ },
154
+ ]);
155
+
156
+ expect(body).toContain(`export const useTranscriptMutation = (
157
+ options?: UseMutationOptions<number, Error, { params: { language: string, speakerCount: number }, files: File[] }> & {
158
+ onUploadProgress?: (e: AxiosProgressEvent) => void;
159
+ }
160
+ ) => useMutation({
161
+ mutationFn: (params: { params: { language: string, speakerCount: number }, files: File[] }) => VoiceService.transcript(params.params, params.files),
162
+ retry: false,
163
+ ...options,
164
+ });`);
165
+ expect(body).not.toContain(
166
+ "mutationFn: (params: { params: { language: string, speakerCount: number }, files: File[] }) => transcript(params.params, params.files)",
167
+ );
168
+ });
169
+ });
@@ -353,7 +353,7 @@ export const ${infiniteHookName} = ${typeParamsDef}(${paramsDef}${
353
353
  functions.push(
354
354
  `
355
355
  export const use${hookName}Mutation = ${typeParamsDef}() => useMutation({
356
- mutationFn: (params: ${mutationParamType}) => ${methodName}(${mutationParamNames})
356
+ mutationFn: (params: ${mutationParamType}) => ${modelName}Service.${methodName}(${mutationParamNames})
357
357
  });
358
358
  `.trim(),
359
359
  );
@@ -378,8 +378,8 @@ export const use${hookName}Mutation = ${typeParamsDef}() => useMutation({
378
378
  // mutationFn 호출 파라미터
379
379
  const mutationFnCall =
380
380
  paramsWithoutContext.length > 0
381
- ? `${methodName}(params.params, params.files)`
382
- : `${methodName}(params.files)`;
381
+ ? `${modelName}Service.${methodName}(params.params, params.files)`
382
+ : `${modelName}Service.${methodName}(params.files)`;
383
383
 
384
384
  functions.push(
385
385
  `