@mandujs/core 0.21.0 → 0.22.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.
Files changed (122) hide show
  1. package/package.json +94 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -1,216 +1,265 @@
1
- /**
2
- * Mandu Session Storage
3
- * 쿠키 기반 서버 사이드 세션 관리
4
- */
5
-
6
- import { type CookieManager, type CookieOptions } from "./context";
7
-
8
- // ========== Types ==========
9
-
10
- export interface SessionData {
11
- [key: string]: unknown;
12
- }
13
-
14
- export interface SessionStorage {
15
- /** 요청의 쿠키에서 세션 가져오기 */
16
- getSession(cookies: CookieManager): Promise<Session>;
17
- /** 세션을 직렬화하여 Set-Cookie 헤더 문자열 반환 */
18
- commitSession(session: Session): Promise<string>;
19
- /** 세션 파기 (쿠키 삭제) */
20
- destroySession(session: Session): Promise<string>;
21
- }
22
-
23
- export interface CookieSessionOptions {
24
- cookie: {
25
- /** 쿠키 이름 (기본: "__session") */
26
- name?: string;
27
- /** HMAC 서명 시크릿 */
28
- secrets: string[];
29
- /** 기본 쿠키 옵션 */
30
- httpOnly?: boolean;
31
- secure?: boolean;
32
- sameSite?: "strict" | "lax" | "none";
33
- maxAge?: number;
34
- path?: string;
35
- domain?: string;
36
- };
37
- }
38
-
39
- // ========== Session Class ==========
40
-
41
- export class Session {
42
- private data: SessionData;
43
- private flash: Map<string, unknown> = new Map();
44
- readonly id: string;
45
-
46
- constructor(data: SessionData = {}, id?: string) {
47
- this.data = { ...data };
48
- this.id = id ?? crypto.randomUUID();
49
- }
50
-
51
- get<T = unknown>(key: string): T | undefined {
52
- // flash 데이터는 한번 읽으면 제거
53
- if (this.flash.has(key)) {
54
- const value = this.flash.get(key);
55
- this.flash.delete(key);
56
- return value as T;
57
- }
58
- return this.data[key] as T | undefined;
59
- }
60
-
61
- set(key: string, value: unknown): void {
62
- this.data[key] = value;
63
- }
64
-
65
- has(key: string): boolean {
66
- return key in this.data || this.flash.has(key);
67
- }
68
-
69
- unset(key: string): void {
70
- delete this.data[key];
71
- }
72
-
73
- /**
74
- * Flash 메시지 — 다음 요청에서 한번만 읽을 수 있는 데이터
75
- * 로그인 성공 메시지, 에러 알림 등에 사용
76
- */
77
- setFlash(key: string, value: unknown): void {
78
- this.flash.set(key, value);
79
- // flash 데이터도 직렬화에 포함
80
- this.data[`__flash_${key}`] = value;
81
- }
82
-
83
- /** 내부 직렬화용 */
84
- toJSON(): SessionData {
85
- return { ...this.data };
86
- }
87
-
88
- /** flash 데이터 복원 */
89
- static fromJSON(data: SessionData): Session {
90
- const session = new Session();
91
- const flashKeys: string[] = [];
92
-
93
- for (const [key, value] of Object.entries(data)) {
94
- if (key.startsWith("__flash_")) {
95
- const realKey = key.slice(8);
96
- session.flash.set(realKey, value);
97
- flashKeys.push(key);
98
- } else {
99
- session.data[key] = value;
100
- }
101
- }
102
-
103
- // flash 키는 data에서 제거 (한번 복원되면 끝)
104
- for (const key of flashKeys) {
105
- delete session.data[key];
106
- }
107
-
108
- return session;
109
- }
110
- }
111
-
112
- // ========== Cookie Session Storage ==========
113
-
114
- /**
115
- * 쿠키 기반 세션 스토리지 생성
116
- *
117
- * @example
118
- * ```typescript
119
- * import { createCookieSessionStorage } from "@mandujs/core";
120
- *
121
- * const sessionStorage = createCookieSessionStorage({
122
- * cookie: {
123
- * name: "__session",
124
- * secrets: [process.env.SESSION_SECRET!],
125
- * httpOnly: true,
126
- * secure: true,
127
- * sameSite: "lax",
128
- * maxAge: 60 * 60 * 24, // 1일
129
- * },
130
- * });
131
- *
132
- * // filling에서 사용
133
- * .action("login", async (ctx) => {
134
- * const session = await sessionStorage.getSession(ctx.cookies);
135
- * session.set("userId", user.id);
136
- * session.setFlash("message", "로그인 성공!");
137
- * const setCookie = await sessionStorage.commitSession(session);
138
- * return ctx.redirect("/dashboard", {
139
- * headers: { "Set-Cookie": setCookie },
140
- * });
141
- * });
142
- * ```
143
- */
144
- export function createCookieSessionStorage(options: CookieSessionOptions): SessionStorage {
145
- const {
146
- name = "__session",
147
- secrets,
148
- httpOnly = true,
149
- secure = process.env.NODE_ENV === "production",
150
- sameSite = "lax",
151
- maxAge = 86400,
152
- path = "/",
153
- domain,
154
- } = options.cookie;
155
-
156
- if (!secrets.length) {
157
- throw new Error("[Mandu Session] At least one secret is required");
158
- }
159
-
160
- const cookieOptions: CookieOptions = {
161
- httpOnly,
162
- secure,
163
- sameSite,
164
- maxAge,
165
- path,
166
- domain,
167
- };
168
-
169
- return {
170
- async getSession(cookies: CookieManager): Promise<Session> {
171
- // Secret rotation: 모든 시크릿으로 검증 시도 (서명은 항상 secrets[0]으로)
172
- for (const secret of secrets) {
173
- const raw = await cookies.getSigned(name, secret);
174
- if (typeof raw === "string" && raw.length > 0) {
175
- try {
176
- const data = JSON.parse(raw) as SessionData;
177
- return Session.fromJSON(data);
178
- } catch {
179
- continue;
180
- }
181
- }
182
- }
183
- return new Session();
184
- },
185
-
186
- async commitSession(session: Session): Promise<string> {
187
- const value = JSON.stringify(session.toJSON());
188
- // 서명된 쿠키로 직렬화
189
- const encoder = new TextEncoder();
190
- const key = await crypto.subtle.importKey(
191
- "raw",
192
- encoder.encode(secrets[0]),
193
- { name: "HMAC", hash: "SHA-256" },
194
- false,
195
- ["sign"]
196
- );
197
- const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
198
- const sigBase64 = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=+$/, "");
199
-
200
- const cookieValue = `${value}.${sigBase64}`;
201
- const parts = [`${name}=${encodeURIComponent(cookieValue)}`];
202
- if (cookieOptions.path) parts.push(`Path=${cookieOptions.path}`);
203
- if (cookieOptions.domain) parts.push(`Domain=${cookieOptions.domain}`);
204
- if (cookieOptions.maxAge) parts.push(`Max-Age=${cookieOptions.maxAge}`);
205
- if (cookieOptions.httpOnly) parts.push("HttpOnly");
206
- if (cookieOptions.secure) parts.push("Secure");
207
- if (cookieOptions.sameSite) parts.push(`SameSite=${cookieOptions.sameSite}`);
208
-
209
- return parts.join("; ");
210
- },
211
-
212
- async destroySession(_session: Session): Promise<string> {
213
- return `${name}=; Path=${path}; Max-Age=0; HttpOnly${secure ? "; Secure" : ""}`;
214
- },
215
- };
216
- }
1
+ /**
2
+ * Mandu Session Storage
3
+ * 쿠키 기반 서버 사이드 세션 관리
4
+ */
5
+
6
+ import { type CookieManager, type CookieOptions } from "./context";
7
+ import { newId } from "../id";
8
+
9
+ // ========== Types ==========
10
+
11
+ export interface SessionData {
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export interface SessionStorage {
16
+ /** 요청의 쿠키에서 세션 가져오기 */
17
+ getSession(cookies: CookieManager): Promise<Session>;
18
+ /** 세션을 직렬화하여 Set-Cookie 헤더 문자열 반환 */
19
+ commitSession(session: Session): Promise<string>;
20
+ /** 세션 파기 (쿠키 삭제) */
21
+ destroySession(session: Session): Promise<string>;
22
+ }
23
+
24
+ export interface CookieSessionOptions {
25
+ cookie: {
26
+ /** 쿠키 이름 (기본: "__session") */
27
+ name?: string;
28
+ /** HMAC 서명 시크릿 */
29
+ secrets: string[];
30
+ /** 기본 쿠키 옵션 */
31
+ httpOnly?: boolean;
32
+ secure?: boolean;
33
+ sameSite?: "strict" | "lax" | "none";
34
+ maxAge?: number;
35
+ path?: string;
36
+ domain?: string;
37
+ };
38
+ }
39
+
40
+ // ========== Session Class ==========
41
+
42
+ export class Session {
43
+ private data: SessionData;
44
+ private flash: Map<string, unknown> = new Map();
45
+ /**
46
+ * Dirty bit set by any mutation (`set`/`unset`/`setFlash`/`clear`) and
47
+ * cleared by `markClean()` after a successful commit. Middleware/helpers use
48
+ * this to skip no-op commits.
49
+ *
50
+ * Intentionally private; observed via `isDirty()` to keep the invariant
51
+ * that only in-class mutations can flip it.
52
+ */
53
+ private _dirty = false;
54
+ readonly id: string;
55
+
56
+ constructor(data: SessionData = {}, id?: string) {
57
+ this.data = { ...data };
58
+ this.id = id ?? newId();
59
+ }
60
+
61
+ get<T = unknown>(key: string): T | undefined {
62
+ // flash 데이터는 한번 읽으면 제거
63
+ if (this.flash.has(key)) {
64
+ const value = this.flash.get(key);
65
+ this.flash.delete(key);
66
+ return value as T;
67
+ }
68
+ return this.data[key] as T | undefined;
69
+ }
70
+
71
+ set(key: string, value: unknown): void {
72
+ this.data[key] = value;
73
+ this._dirty = true;
74
+ }
75
+
76
+ has(key: string): boolean {
77
+ return key in this.data || this.flash.has(key);
78
+ }
79
+
80
+ unset(key: string): void {
81
+ // Preserve original unconditional-delete semantics; flip dirty regardless
82
+ // so callers can observe intent via `isDirty()` even when the key was
83
+ // already absent.
84
+ delete this.data[key];
85
+ this._dirty = true;
86
+ }
87
+
88
+ /**
89
+ * Flash 메시지 다음 요청에서 한번만 읽을 수 있는 데이터
90
+ * 로그인 성공 메시지, 에러 알림 등에 사용
91
+ */
92
+ setFlash(key: string, value: unknown): void {
93
+ this.flash.set(key, value);
94
+ // flash 데이터도 직렬화에 포함
95
+ this.data[`__flash_${key}`] = value;
96
+ this._dirty = true;
97
+ }
98
+
99
+ /**
100
+ * Whether this session has been mutated since it was constructed or last
101
+ * cleaned via {@link markClean}. `saveSession` consults this to avoid
102
+ * re-committing unchanged sessions.
103
+ */
104
+ isDirty(): boolean {
105
+ return this._dirty;
106
+ }
107
+
108
+ /**
109
+ * Reset the dirty bit. Called by `saveSession` after a successful
110
+ * `commitSession`. Not intended for handler code.
111
+ *
112
+ * @internal
113
+ */
114
+ markClean(): void {
115
+ this._dirty = false;
116
+ }
117
+
118
+ /**
119
+ * Wipe in-memory data + flash. Called by `destroySession` so subsequent
120
+ * handler code sees an empty session. Flips the dirty bit.
121
+ */
122
+ clear(): void {
123
+ this.data = {};
124
+ this.flash.clear();
125
+ this._dirty = true;
126
+ }
127
+
128
+ /** 내부 직렬화용 */
129
+ toJSON(): SessionData {
130
+ return { ...this.data };
131
+ }
132
+
133
+ /** flash 데이터 복원 */
134
+ static fromJSON(data: SessionData): Session {
135
+ const session = new Session();
136
+ const flashKeys: string[] = [];
137
+
138
+ for (const [key, value] of Object.entries(data)) {
139
+ if (key.startsWith("__flash_")) {
140
+ const realKey = key.slice(8);
141
+ session.flash.set(realKey, value);
142
+ flashKeys.push(key);
143
+ } else {
144
+ session.data[key] = value;
145
+ }
146
+ }
147
+
148
+ // flash 키는 data에서 제거 (한번 복원되면 끝)
149
+ for (const key of flashKeys) {
150
+ delete session.data[key];
151
+ }
152
+
153
+ // Loaded-from-cookie state is, by definition, clean until handler code
154
+ // mutates it. This runs after data population so any mutations above
155
+ // don't accidentally leave _dirty=true.
156
+ session._dirty = false;
157
+ return session;
158
+ }
159
+ }
160
+
161
+ // ========== Cookie Session Storage ==========
162
+
163
+ /**
164
+ * 쿠키 기반 세션 스토리지 생성
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * import { createCookieSessionStorage } from "@mandujs/core";
169
+ *
170
+ * const sessionStorage = createCookieSessionStorage({
171
+ * cookie: {
172
+ * name: "__session",
173
+ * secrets: [process.env.SESSION_SECRET!],
174
+ * httpOnly: true,
175
+ * secure: true,
176
+ * sameSite: "lax",
177
+ * maxAge: 60 * 60 * 24, // 1일
178
+ * },
179
+ * });
180
+ *
181
+ * // filling에서 사용
182
+ * .action("login", async (ctx) => {
183
+ * const session = await sessionStorage.getSession(ctx.cookies);
184
+ * session.set("userId", user.id);
185
+ * session.setFlash("message", "로그인 성공!");
186
+ * const setCookie = await sessionStorage.commitSession(session);
187
+ * return ctx.redirect("/dashboard", {
188
+ * headers: { "Set-Cookie": setCookie },
189
+ * });
190
+ * });
191
+ * ```
192
+ */
193
+ export function createCookieSessionStorage(options: CookieSessionOptions): SessionStorage {
194
+ const {
195
+ name = "__session",
196
+ secrets,
197
+ httpOnly = true,
198
+ secure = process.env.NODE_ENV === "production",
199
+ sameSite = "lax",
200
+ maxAge = 86400,
201
+ path = "/",
202
+ domain,
203
+ } = options.cookie;
204
+
205
+ if (!secrets.length) {
206
+ throw new Error("[Mandu Session] At least one secret is required");
207
+ }
208
+
209
+ const cookieOptions: CookieOptions = {
210
+ httpOnly,
211
+ secure,
212
+ sameSite,
213
+ maxAge,
214
+ path,
215
+ domain,
216
+ };
217
+
218
+ return {
219
+ async getSession(cookies: CookieManager): Promise<Session> {
220
+ // Secret rotation: 모든 시크릿으로 검증 시도 (서명은 항상 secrets[0]으로)
221
+ for (const secret of secrets) {
222
+ const raw = await cookies.getSigned(name, secret);
223
+ if (typeof raw === "string" && raw.length > 0) {
224
+ try {
225
+ const data = JSON.parse(raw) as SessionData;
226
+ return Session.fromJSON(data);
227
+ } catch {
228
+ continue;
229
+ }
230
+ }
231
+ }
232
+ return new Session();
233
+ },
234
+
235
+ async commitSession(session: Session): Promise<string> {
236
+ const value = JSON.stringify(session.toJSON());
237
+ // 서명된 쿠키로 직렬화
238
+ const encoder = new TextEncoder();
239
+ const key = await crypto.subtle.importKey(
240
+ "raw",
241
+ encoder.encode(secrets[0]),
242
+ { name: "HMAC", hash: "SHA-256" },
243
+ false,
244
+ ["sign"]
245
+ );
246
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
247
+ const sigBase64 = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=+$/, "");
248
+
249
+ const cookieValue = `${value}.${sigBase64}`;
250
+ const parts = [`${name}=${encodeURIComponent(cookieValue)}`];
251
+ if (cookieOptions.path) parts.push(`Path=${cookieOptions.path}`);
252
+ if (cookieOptions.domain) parts.push(`Domain=${cookieOptions.domain}`);
253
+ if (cookieOptions.maxAge) parts.push(`Max-Age=${cookieOptions.maxAge}`);
254
+ if (cookieOptions.httpOnly) parts.push("HttpOnly");
255
+ if (cookieOptions.secure) parts.push("Secure");
256
+ if (cookieOptions.sameSite) parts.push(`SameSite=${cookieOptions.sameSite}`);
257
+
258
+ return parts.join("; ");
259
+ },
260
+
261
+ async destroySession(_session: Session): Promise<string> {
262
+ return `${name}=; Path=${path}; Max-Age=0; HttpOnly${secure ? "; Secure" : ""}`;
263
+ },
264
+ };
265
+ }
@@ -226,24 +226,33 @@ describe("Decision Memory", () => {
226
226
 
227
227
  describe("saveDecision", () => {
228
228
  it("should save new decision as markdown file", async () => {
229
- const newDecision: Omit<ArchitectureDecision, "date"> = {
230
- id: "ADR-003",
231
- title: "Use Feature Flags",
232
- status: "proposed",
233
- tags: ["feature", "deployment"],
234
- context: "Need controlled rollout",
235
- decision: "Use feature flags for gradual rollout",
236
- consequences: ["Need flag management system"],
237
- };
238
-
239
- const result = await saveDecision(TEST_DIR, newDecision);
240
-
241
- expect(result.success).toBe(true);
242
- expect(result.filePath).toContain("ADR-003");
243
-
244
- // 파일이 실제로 생성되었는지 확인
245
- const content = await readFile(result.filePath, "utf-8");
246
- expect(content).toContain("Use Feature Flags");
229
+ // Local tempdir so the added ADR-003 does not leak into the shared
230
+ // TEST_DIR used by getAllDecisions / searchDecisions / …, which
231
+ // assert exact counts or specific ADR contents under --randomize.
232
+ const localDir = await mkdtemp(join(tmpdir(), "save-decision-test-"));
233
+ try {
234
+ await mkdir(join(localDir, "spec", "decisions"), { recursive: true });
235
+ const newDecision: Omit<ArchitectureDecision, "date"> = {
236
+ id: "ADR-003",
237
+ title: "Use Feature Flags",
238
+ status: "proposed",
239
+ tags: ["feature", "deployment"],
240
+ context: "Need controlled rollout",
241
+ decision: "Use feature flags for gradual rollout",
242
+ consequences: ["Need flag management system"],
243
+ };
244
+
245
+ const result = await saveDecision(localDir, newDecision);
246
+
247
+ expect(result.success).toBe(true);
248
+ expect(result.filePath).toContain("ADR-003");
249
+
250
+ // 파일이 실제로 생성되었는지 확인
251
+ const content = await readFile(result.filePath, "utf-8");
252
+ expect(content).toContain("Use Feature Flags");
253
+ } finally {
254
+ await rm(localDir, { recursive: true, force: true });
255
+ }
247
256
  });
248
257
  });
249
258
 
@@ -275,10 +284,31 @@ describe("Decision Memory", () => {
275
284
 
276
285
  describe("getNextDecisionId", () => {
277
286
  it("should return next sequential ID", async () => {
278
- // ADR-003을 추가했으므로 다음은 ADR-004
279
- const nextId = await getNextDecisionId(TEST_DIR);
280
-
281
- expect(nextId).toBe("ADR-004");
287
+ // Use an isolated tempdir so other tests in this suite (which assert
288
+ // on the shared TEST_DIR's ADR count) are not polluted by the fixture
289
+ // this test needs.
290
+ const localDir = await mkdtemp(join(tmpdir(), "next-id-test-"));
291
+ try {
292
+ await mkdir(join(localDir, "spec", "decisions"), { recursive: true });
293
+ await Bun.write(
294
+ join(localDir, "spec", "decisions", "ADR-001-a.md"),
295
+ "**ID:** ADR-001\n"
296
+ );
297
+ await Bun.write(
298
+ join(localDir, "spec", "decisions", "ADR-002-b.md"),
299
+ "**ID:** ADR-002\n"
300
+ );
301
+ await Bun.write(
302
+ join(localDir, "spec", "decisions", "ADR-003-c.md"),
303
+ "**ID:** ADR-003\n"
304
+ );
305
+
306
+ const nextId = await getNextDecisionId(localDir);
307
+
308
+ expect(nextId).toBe("ADR-004");
309
+ } finally {
310
+ await rm(localDir, { recursive: true, force: true });
311
+ }
282
312
  });
283
313
 
284
314
  it("should return ADR-001 for empty project", async () => {