sonamu 0.10.8 → 0.10.9

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,227 @@
1
+ import { type Knex } from "knex";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { type DBPreset, DBClass } from "../db";
5
+ import { PuriTransactionWrapper } from "../puri-wrapper";
6
+ import { UpsertBuilder } from "../upsert-builder";
7
+
8
+ type TransactionScopeDB = DBClass & {
9
+ runWithTransactionScope?<T>(
10
+ preset: DBPreset,
11
+ transaction: PuriTransactionWrapper,
12
+ callback: () => Promise<T>,
13
+ ): Promise<T>;
14
+ };
15
+
16
+ function createTransactionWrapper(): PuriTransactionWrapper {
17
+ return new PuriTransactionWrapper({} as Knex.Transaction, new UpsertBuilder());
18
+ }
19
+
20
+ function createDeferred() {
21
+ let resolve!: () => void;
22
+ const promise = new Promise<void>((done) => {
23
+ resolve = done;
24
+ });
25
+ return { promise, resolve };
26
+ }
27
+
28
+ function expectScopeRunner(db: TransactionScopeDB) {
29
+ expect(db.runWithTransactionScope).toBeTypeOf("function");
30
+ return db.runWithTransactionScope;
31
+ }
32
+
33
+ describe("DB.runWithTransactionScope", () => {
34
+ it("저장소가 없어도 루트 scope를 설치하고 콜백 결과를 반환한 뒤 정리한다", async () => {
35
+ const db = new DBClass() as TransactionScopeDB;
36
+ const transaction = createTransactionWrapper();
37
+ const runScope = expectScopeRunner(db);
38
+ if (!runScope) return;
39
+
40
+ let callbackContext: ReturnType<DBClass["getTransactionContext"]> | undefined;
41
+ const result = await runScope.call(db, "w", transaction, async () => {
42
+ callbackContext = db.getTransactionContext();
43
+ expect(db.transactionStorage.getStore()).toBe(callbackContext);
44
+ expect(callbackContext.getActiveTransaction()).toBe(transaction);
45
+ expect(callbackContext.getTransaction("w")).toBe(transaction);
46
+ return "완료";
47
+ });
48
+
49
+ expect(result).toBe("완료");
50
+ expect(callbackContext?.getActiveTransaction()).toBeUndefined();
51
+ expect(callbackContext?.getTransaction("w")).toBeUndefined();
52
+ expect(db.transactionStorage.getStore()).toBeUndefined();
53
+ });
54
+
55
+ it("scope 안에서 생성된 detached descendant가 완료된 자식 트랜잭션을 다시 사용하지 않는다", async () => {
56
+ const db = new DBClass() as TransactionScopeDB;
57
+ const parentTransaction = createTransactionWrapper();
58
+ const childTransaction = createTransactionWrapper();
59
+ const descendantReady = createDeferred();
60
+ const resumeDescendant = createDeferred();
61
+ const runScope = expectScopeRunner(db);
62
+ if (!runScope) return;
63
+
64
+ let parentContext: ReturnType<DBClass["getTransactionContext"]> | undefined;
65
+ let childContext: ReturnType<DBClass["getTransactionContext"]> | undefined;
66
+
67
+ await runScope.call(db, "r", parentTransaction, async () => {
68
+ parentContext = db.getTransactionContext();
69
+
70
+ let descendantPromise:
71
+ | Promise<{
72
+ context: ReturnType<DBClass["getTransactionContext"]>;
73
+ active: PuriTransactionWrapper | undefined;
74
+ write: PuriTransactionWrapper | undefined;
75
+ read: PuriTransactionWrapper | undefined;
76
+ }>
77
+ | undefined;
78
+
79
+ childContext = await runScope.call(db, "w", childTransaction, async () => {
80
+ const scopedContext = db.getTransactionContext();
81
+ descendantPromise = (async () => {
82
+ descendantReady.resolve();
83
+ await resumeDescendant.promise;
84
+ const context = db.getTransactionContext();
85
+ return {
86
+ context,
87
+ active: context.getActiveTransaction(),
88
+ write: context.getTransaction("w"),
89
+ read: context.getTransaction("r"),
90
+ };
91
+ })();
92
+
93
+ await descendantReady.promise;
94
+ return scopedContext;
95
+ });
96
+
97
+ expect(db.getTransactionContext()).toBe(parentContext);
98
+ expect(childContext.getActiveTransaction()).toBe(parentTransaction);
99
+ expect(childContext.getTransaction("w")).toBeUndefined();
100
+ expect(childContext.getTransaction("r")).toBe(parentTransaction);
101
+
102
+ resumeDescendant.resolve();
103
+ if (!descendantPromise) {
104
+ throw new Error("detached descendant가 생성되지 않았습니다");
105
+ }
106
+ const descendantResult = await descendantPromise;
107
+
108
+ expect(descendantResult.context).toBe(childContext);
109
+ expect(descendantResult.active).toBe(parentTransaction);
110
+ expect(descendantResult.write).toBeUndefined();
111
+ expect(descendantResult.read).toBe(parentTransaction);
112
+ });
113
+
114
+ expect(parentContext?.getActiveTransaction()).toBeUndefined();
115
+ expect(parentContext?.getTransaction("r")).toBeUndefined();
116
+ expect(childContext?.getActiveTransaction()).toBeUndefined();
117
+ expect(childContext?.getTransaction("r")).toBeUndefined();
118
+ expect(db.transactionStorage.getStore()).toBeUndefined();
119
+ });
120
+
121
+ it("같거나 다른 preset의 중첩 범위를 복원하고 같은 오류를 다시 던진다", async () => {
122
+ const db = new DBClass() as TransactionScopeDB;
123
+ const outer = createTransactionWrapper();
124
+ const samePresetInner = createTransactionWrapper();
125
+ const differentPresetInner = createTransactionWrapper();
126
+ const expectedError = new Error("의도한 scope 오류");
127
+ const runScope = expectScopeRunner(db);
128
+ if (!runScope) return;
129
+
130
+ await runScope.call(db, "w", outer, async () => {
131
+ const outerContext = db.getTransactionContext();
132
+ expect(outerContext.getActiveTransaction()).toBe(outer);
133
+
134
+ await runScope.call(db, "w", samePresetInner, async () => {
135
+ expect(db.getTransactionContext().getActiveTransaction()).toBe(samePresetInner);
136
+ expect(db.getTransactionContext().getTransaction("w")).toBe(samePresetInner);
137
+ });
138
+ expect(db.getTransactionContext()).toBe(outerContext);
139
+ expect(outerContext.getActiveTransaction()).toBe(outer);
140
+
141
+ await expect(
142
+ runScope.call(db, "fixture", differentPresetInner, async () => {
143
+ const innerContext = db.getTransactionContext();
144
+ expect(innerContext.getActiveTransaction()).toBe(differentPresetInner);
145
+ expect(innerContext.getTransaction("w")).toBe(outer);
146
+ expect(innerContext.getTransaction("fixture")).toBe(differentPresetInner);
147
+ throw expectedError;
148
+ }),
149
+ ).rejects.toBe(expectedError);
150
+
151
+ expect(db.getTransactionContext()).toBe(outerContext);
152
+ expect(outerContext.getActiveTransaction()).toBe(outer);
153
+ expect(outerContext.getTransaction("fixture")).toBeUndefined();
154
+ });
155
+
156
+ expect(db.transactionStorage.getStore()).toBeUndefined();
157
+ });
158
+
159
+ it("겹치는 sibling 범위가 완료 순서와 무관하게 서로의 활성 트랜잭션을 보지 않는다", async () => {
160
+ const db = new DBClass() as TransactionScopeDB;
161
+ const parentTransaction = createTransactionWrapper();
162
+ const firstTransaction = createTransactionWrapper();
163
+ const secondTransaction = createTransactionWrapper();
164
+ const firstReady = createDeferred();
165
+ const secondReady = createDeferred();
166
+ const firstExited = createDeferred();
167
+ const runScope = expectScopeRunner(db);
168
+ if (!runScope) return;
169
+
170
+ let firstContext: ReturnType<DBClass["getTransactionContext"]> | undefined;
171
+ let secondContext: ReturnType<DBClass["getTransactionContext"]> | undefined;
172
+
173
+ await runScope.call(db, "r", parentTransaction, async () => {
174
+ const parentContext = db.getTransactionContext();
175
+ const firstPromise = runScope.call(db, "w", firstTransaction, async () => {
176
+ firstReady.resolve();
177
+ await secondReady.promise;
178
+ return {
179
+ context: db.getTransactionContext(),
180
+ active: db.getTransactionContext().getActiveTransaction(),
181
+ write: db.getTransactionContext().getTransaction("w"),
182
+ inheritedRead: db.getTransactionContext().getTransaction("r"),
183
+ };
184
+ });
185
+
186
+ await firstReady.promise;
187
+ const secondPromise = runScope.call(db, "w", secondTransaction, async () => {
188
+ secondReady.resolve();
189
+ await firstExited.promise;
190
+ return {
191
+ context: db.getTransactionContext(),
192
+ active: db.getTransactionContext().getActiveTransaction(),
193
+ write: db.getTransactionContext().getTransaction("w"),
194
+ inheritedRead: db.getTransactionContext().getTransaction("r"),
195
+ };
196
+ });
197
+
198
+ const firstResult = await firstPromise;
199
+ firstExited.resolve();
200
+ const secondResult = await secondPromise;
201
+ firstContext = firstResult.context;
202
+ secondContext = secondResult.context;
203
+
204
+ expect(firstResult.context).not.toBe(secondResult.context);
205
+ expect(firstResult.active).toBe(firstTransaction);
206
+ expect(firstResult.write).toBe(firstTransaction);
207
+ expect(firstResult.inheritedRead).toBe(parentTransaction);
208
+ expect(secondResult.active).toBe(secondTransaction);
209
+ expect(secondResult.write).toBe(secondTransaction);
210
+ expect(secondResult.inheritedRead).toBe(parentTransaction);
211
+ expect(firstResult.context.getActiveTransaction()).toBe(parentTransaction);
212
+ expect(secondResult.context.getActiveTransaction()).toBe(parentTransaction);
213
+ expect(db.getTransactionContext()).toBe(parentContext);
214
+ expect(parentContext.getActiveTransaction()).toBe(parentTransaction);
215
+ });
216
+
217
+ expect(firstContext?.getActiveTransaction()).toBeUndefined();
218
+ expect(secondContext?.getActiveTransaction()).toBeUndefined();
219
+ expect(db.transactionStorage.getStore()).toBeUndefined();
220
+ });
221
+
222
+ it("기존 bootstrap helper를 런타임 API로 노출하지 않는다", () => {
223
+ const db = new DBClass();
224
+
225
+ expect(db).not.toHaveProperty("runWithTransaction");
226
+ });
227
+ });
@@ -67,6 +67,15 @@ export class BaseModelClass<
67
67
  return new PuriWrapper(db, new UpsertBuilder());
68
68
  }
69
69
 
70
+ private getSubsetQueryPuri(): PuriWrapper {
71
+ const activeTransaction = DB.getTransactionContext().getActiveTransaction();
72
+ if (activeTransaction) {
73
+ return activeTransaction;
74
+ }
75
+
76
+ return new PuriWrapper(this.getDB("r"), new UpsertBuilder());
77
+ }
78
+
70
79
  async destroy() {
71
80
  return DB.destroy();
72
81
  }
@@ -120,7 +129,7 @@ export class BaseModelClass<
120
129
  throw new Error("subsetQueries is not defined");
121
130
  }
122
131
 
123
- const puriWrapper = new PuriWrapper(this.getDB("r"), new UpsertBuilder());
132
+ const puriWrapper = this.getSubsetQueryPuri();
124
133
  const qb = this.subsetQueries[subset]?.(puriWrapper);
125
134
 
126
135
  // NonAllowedAsSingleTable: 단일 테이블 컬럼 접근 방지용 마커
@@ -201,6 +210,8 @@ export class BaseModelClass<
201
210
  }
202
211
 
203
212
  const { num, page } = queryParams;
213
+ // 모든 로더가 root query와 같은 연결을 사용하도록 실행 시점의 knex에서 한 번만 파생한다.
214
+ const puriWrapper = new PuriWrapper(qb.knex, new UpsertBuilder());
204
215
 
205
216
  // COUNT 쿼리 실행 (queryMode: list일 때는 0 리턴)
206
217
  const total = await this.executeCountQuery(qb, queryParams, debug, optimizeCountQuery);
@@ -210,7 +221,15 @@ export class BaseModelClass<
210
221
  }
211
222
 
212
223
  // LIST 쿼리 실행
213
- const computedRows = await this.executeListQuery(subset, qb, queryParams, num, page, debug);
224
+ const computedRows = await this.executeListQuery(
225
+ subset,
226
+ qb,
227
+ queryParams,
228
+ num,
229
+ page,
230
+ debug,
231
+ puriWrapper,
232
+ );
214
233
 
215
234
  // Enhancer 적용
216
235
  const enhancer = (params as any).enhancers?.[subset];
@@ -457,6 +476,7 @@ export class BaseModelClass<
457
476
  num: number,
458
477
  page: number,
459
478
  debug: boolean,
479
+ puriWrapper: PuriWrapper,
460
480
  ): Promise<any[]> {
461
481
  if (params.queryMode === "count") {
462
482
  return [];
@@ -478,7 +498,7 @@ export class BaseModelClass<
478
498
  // 로더 처리
479
499
  const loaders = (this.loaderQueries as any)[subset];
480
500
  if (loaders && Array.isArray(loaders)) {
481
- unloadedRows = await this.processLoaders(unloadedRows, loaders, debug);
501
+ unloadedRows = await this.processLoaders(unloadedRows, loaders, debug, puriWrapper);
482
502
  }
483
503
 
484
504
  return this.hydrate(unloadedRows);
@@ -487,12 +507,17 @@ export class BaseModelClass<
487
507
  /**
488
508
  * 재귀적 로더 처리
489
509
  */
490
- private async processLoaders(rows: any[], loaders: any[], debug: boolean): Promise<any[]> {
510
+ private async processLoaders(
511
+ rows: any[],
512
+ loaders: any[],
513
+ debug: boolean,
514
+ puriWrapper: PuriWrapper,
515
+ ): Promise<any[]> {
491
516
  for (const resolveLoader of loaders) {
492
517
  const { as, refId, qb: resolveLoaderQbFn, loaders: nestedLoaders } = resolveLoader;
493
518
 
494
519
  const resolveLoaderQb = resolveLoaderQbFn(
495
- new PuriWrapper(this.getDB("r"), new UpsertBuilder()),
520
+ puriWrapper,
496
521
  rows.map((row) => row[refId]),
497
522
  );
498
523
 
@@ -504,7 +529,7 @@ export class BaseModelClass<
504
529
 
505
530
  // 중첩 loaders가 있으면 재귀 처리
506
531
  if (nestedLoaders && nestedLoaders.length > 0) {
507
- loadedRows = await this.processLoaders(loadedRows, nestedLoaders, debug);
532
+ loadedRows = await this.processLoaders(loadedRows, nestedLoaders, debug, puriWrapper);
508
533
  }
509
534
 
510
535
  const subRowGroups = group(loadedRows, (row) => row.refId);
@@ -6,6 +6,7 @@ import { type Knex } from "knex";
6
6
  import { type DatabaseConfig, type SonamuConfig } from "../api/config";
7
7
  import { getSonamuEnvironment, type EnvironmentSnapshots, type SonamuEnvironment } from "../env";
8
8
  import { createKnexInstance } from "./knex";
9
+ import { type PuriTransactionWrapper } from "./puri-wrapper";
9
10
  import { TransactionContext } from "./transaction-context";
10
11
 
11
12
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -155,8 +156,22 @@ export class DBClass {
155
156
 
156
157
  public transactionStorage = new AsyncLocalStorage<TransactionContext>();
157
158
 
158
- public runWithTransaction<T>(callback: () => Promise<T>): Promise<T> {
159
- return this.transactionStorage.run(new TransactionContext(), callback);
159
+ public runWithTransactionScope<T>(
160
+ preset: DBPreset,
161
+ transaction: PuriTransactionWrapper,
162
+ callback: () => Promise<T>,
163
+ ): Promise<T> {
164
+ const parentContext = this.transactionStorage.getStore();
165
+ const transactionContext = new TransactionContext(parentContext, { preset, transaction });
166
+
167
+ return this.transactionStorage.run(transactionContext, async () => {
168
+ try {
169
+ return await callback();
170
+ } finally {
171
+ // detached descendant가 완료된 트랜잭션을 다시 사용하지 않도록 local scope를 비운다.
172
+ transactionContext.clearLocal();
173
+ }
174
+ });
160
175
  }
161
176
 
162
177
  setConfig(dbConfig: SonamuDBConfig): void {
@@ -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> {