@gaonjs/cli 0.41.3 → 0.41.5

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/dist/serve.js CHANGED
@@ -133,7 +133,20 @@ export async function runServeCommand(opts = {}) {
133
133
  };
134
134
  const configPath = findConfigPath(cwd);
135
135
  const config = await loadGaonConfig(cwd);
136
- const wired = await wireGaon(config, cwd);
136
+ // 결정 252(결정 250 확장): wireGaon 이 부팅 도중(mid-wire · 예: 잘못된 DB 자격증명으로
137
+ // ensureOutboxTable 의 첫 DB 접근이 throw) 실패하면, wireGaon 이 자기가 연 부분 핸들(NATS·
138
+ // Redis·DB)을 정리하고 rethrow 한다. 이전엔 이 호출이 try 밖이라, 상위(index.ts)가 exitCode 만
139
+ // 세팅하고 열린 소켓이 이벤트 루프를 붙잡아 프로세스가 종료하지 못하고 매달렸다(25s 미종료 실측).
140
+ // 여기서 fail-loud + 명시 종료로 확정 종료를 보장한다(post-wire 실패[아래]와 대칭 · exit 는 실패 시만).
141
+ let wired;
142
+ try {
143
+ wired = await wireGaon(config, cwd);
144
+ }
145
+ catch (err) {
146
+ const msg = err instanceof Error ? err.message : String(err);
147
+ process.stderr.write(` ✗ gaon serve 부팅 실패: ${msg}\n`);
148
+ process.exit(1);
149
+ }
137
150
  // 결정 250(샘플 C · P2): wireGaon 이 NATS·Redis 소켓을 연 뒤의 부팅 단계(포트 파싱·listen
138
151
  // 등)에서 실패하면, 상위 catch(index.ts)가 exitCode=1 만 세팅하고 끝나 열린 소켓이 이벤트
139
152
  // 루프를 붙잡아 프로세스가 종료하지 못하고 매달린다(정확한 fail-loud 메시지는 찍히지만
@@ -3,7 +3,7 @@
3
3
  이 문서는 **AI 코딩 에이전트**(Claude · Codex · Cursor · Copilot 등)와
4
4
  사람 개발자가 Gaon 프로젝트에서 작업할 때 참조하는 관례의 진입점이다.
5
5
  정본은 설계 문서(`docs/gaondesignv0.17.md` · v1.0 출시 기준 스냅샷 ·
6
- v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구조**다 (결정 40):
6
+ v0.15+errata→v0.16→v0.17 · 결정 31~91)이며, 관례 문서는 **2층 구조**다 (결정 40):
7
7
 
8
8
  - **이 파일 (코어)** — 절대 규칙 · 로직 배치 판단표 · 검증 루프 ·
9
9
  카테고리 색인. 여기엔 요약만 있다.
@@ -264,7 +264,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
264
264
 
265
265
  ## 7. 참고 문서
266
266
 
267
- - 설계 정본: `docs/gaondesignv0.17.md` (v1.0 출시 기준 스냅샷 · 결정 31~89) ·
267
+ - 설계 정본: `docs/gaondesignv0.17.md` (v1.0 출시 기준 스냅샷 · 결정 31~91) ·
268
268
  이력 동결 = `gaondesignv0.16.md`·`v0.15.md` + errata E-1~E-5
269
269
  (E-1 파사드명 · E-2 실시간 TCP · E-3 JSON 액션/params · E-4 컬럼·
270
270
  체이닝 · E-5 컴포저블·레이아웃).
@@ -55,7 +55,7 @@ export default schedule((s) => {
55
55
 
56
56
  ## 정본 규칙
57
57
 
58
- ### 1. 잡 (`job()`) (`packages/async/src/jobs.ts:146-185`)
58
+ ### 1. 잡 (`job()`) (`packages/async/src/jobs.ts`)
59
59
 
60
60
  잡은 도메인 소속이다 — `domain/jobs/*.ts` 에 파일을 놓으면 등록이고,
61
61
  어느 앱에서 큐잉하든 같은 워커(`gaon work`)가 처리한다. 모델·서비스와
@@ -240,6 +240,8 @@ export default schedule((s) => {
240
240
  - **소비 = 워커 전체 분산** — 발행된 잡은 NATS JetStream 큐 그룹으로 **모든**
241
241
  워커에 로드밸런싱된다. 발행은 1인, 처리는 N인.
242
242
  - **exactly-once(발행 기준)** — 한 스케줄 틱은 리더 1인이 한 번만 발행한다.
243
+ 리더 교체(페일오버) 순간 구·신 리더가 같은 틱을 겹쳐 발행해도, 결정론적
244
+ dedupe 키 + JetStream 중복 윈도우가 이를 1회로 수렴시킨다(결정 233).
243
245
  잡 자체는 재시도(백오프)가 있으니 **핸들러는 멱등**하게 짠다(같은 잡이 두 번
244
246
  처리돼도 안전하게).
245
247
  - **`gaon serve` 는 스케줄러를 돌리지 않는다** — 스케줄·리더 선출·아웃박스
@@ -248,7 +250,7 @@ export default schedule((s) => {
248
250
  원인은 `gaon work` 를 안 띄운 것이다(개발은 `gaon dev` 가 work 를 자동 기동
249
251
  하므로 해당 없음 · 결정 211 · §6).
250
252
 
251
- #### 시간대 (결정 202)
253
+ #### 시간대 (결정 202·230)
252
254
 
253
255
  `s.daily.at('04:00', Job)`·`s.cron('0 9 * * 1', Job)` 는 **서버 로컬 타임존**의
254
256
  wall-clock 으로 매치한다(`new Date()` 로컬 시·분·요일). v1 은 **잡별 타임존
@@ -320,8 +322,9 @@ drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤
320
322
  따로 놀아 무의미하다(결정 88 ①). 그래서 백엔드는 Redis 다: 설정에 `redis`
321
323
  가 있으면 **`gaon serve` 와 `gaon work` 둘 다** 분산 락을 자동 배선한다(결정
322
324
  202 · wireDomain 공통 경로) — 그래서 **잡·서비스 안에서도 `lock()` 을 쓸 수
323
- 있다**(예: 중첩 방지 · §5). **`redis` 미설정 상태로 `lock()` 을 부르면 로컬
324
- 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께 throw** 한다.
325
+ 있다**(예: 중첩 방지 · §5). **운영(`NODE_ENV=production`)에서 `redis` 미설정
326
+ 상태로 `lock()` 을 부르면 로컬 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께
327
+ throw** 한다(개발·테스트는 in-memory 백엔드로 폴백해 단일 프로세스에서 그대로 돈다).
325
328
 
326
329
  ```ts
327
330
  import { lock } from 'gaonjs/async'
@@ -392,8 +395,8 @@ async create() {
392
395
  (`agents/testing.md`).
393
396
  - **동시 실행 방지에 로컬 뮤텍스·플래그 금지** (결정 147) — `let running = false`
394
397
  같은 프로세스 로컬 가드는 멀티 인스턴스에서 안 먹는다. `lock(key, fn)` 을
395
- 쓴다. `redis` 미설정이면 `lock()` 이 수리 안내로 throw 하니 조용한 파손이
396
- 없다.
398
+ 쓴다. 운영에서 `redis` 미설정이면 `lock()` 이 수리 안내로 throw 하니 조용한
399
+ 파손이 없다(개발·테스트는 in-memory 폴백).
397
400
 
398
401
  ## 관련 결정 번호
399
402
 
@@ -406,6 +409,8 @@ async create() {
406
409
  | 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`agents/testing.md`) |
407
410
  | 결정 200 | DLQ 재처리 `retryDlq(nats, id)` (이미 설정된 잡 전송 보존 · DLQ 재적재 후 원본 삭제) |
408
411
  | 결정 201 | graceful drain 계약 (serve 인플라이트 요청 완결 · work 진행 잡 완결 · §6) |
409
- | 결정 202 | 락·캐시 백엔드 serve·work 공통 배선(wireDomain) · 잡/서비스 lock() 가능 · 스케줄러 시간대(서버 로컬 TZ)·중첩 방지(§5·§7) |
412
+ | 결정 202 | 락·캐시 백엔드 serve·work 공통 배선(wireDomain) · 잡/서비스 lock() 가능 · 스케줄러 중첩 방지(§5·§7) |
413
+ | 결정 230 | 스케줄러/앱 시간대 = `gaon.config.ts` 의 `timezone` → `process.env.TZ`(config 가 런치 `TZ` 보다 우선 · 앱 전역 단일 타임존 · §5) |
414
+ | 결정 233 | 크론 리더 페일오버 중복 발행 dedupe (결정론적 dedupe 키 + JetStream 중복 윈도우로 틱당 1회 발행 수렴 · §5) |
410
415
  | 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
411
416
  | §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
@@ -100,7 +100,7 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
100
100
  이지 `created_at` 아님). 파일명 casing 은 doctor `schema-filename` 이
101
101
  강제한다(단수/복수는 대상 밖 · 결정 38).
102
102
 
103
- ### 2. 컬럼 타입 전체 (실 구현 · `packages/data/src/schema.ts:394-445`)
103
+ ### 2. 컬럼 타입 전체 (실 구현 · `packages/data/src/schema.ts`)
104
104
 
105
105
  | 빌더 | SQL 타입 | TS 타입 | 비고 |
106
106
  |---|---|---|---|
@@ -162,14 +162,14 @@ export const posts = table('posts', {
162
162
  })
163
163
  ```
164
164
 
165
- ### 4. 체이닝 전체 (`packages/data/src/model.ts:136-338`)
165
+ ### 4. 체이닝 전체 (`packages/data/src/model.ts`)
166
166
 
167
167
  체이닝 표면은 아래 표가 **전부**다. 표에 없는 메서드
168
168
  (`destroy`·`findBy`·`paginate`·`order`·해시 인자
169
169
  `where({...})` 같은 다른 ORM 관습)를 추측해서 쓰지 말 것 — 표
170
170
  바깥의 쿼리는 §5 `Post.query()` 탈출구로 내려간다.
171
171
 
172
- **where op 12종** (`model.ts:61-71`):
172
+ **where op 12종** (`model.ts`):
173
173
 
174
174
  | 분류 | op | val 인자 |
175
175
  |---|---|---|
@@ -177,7 +177,7 @@ export const posts = table('posts', {
177
177
  | 컬렉션 (`WhereOpIn`) | `in` `not in` | `ReadonlyArray<Row[col]>` |
178
178
  | Null (`WhereOpNull`) | `is null` `is not null` | **없음** — 2-인자 호출 `where('deletedAt', 'is null')` |
179
179
 
180
- **체이닝 메서드 전체** (`Chain` · `model.ts:79-109`):
180
+ **체이닝 메서드 전체** (`Chain` · `model.ts`):
181
181
 
182
182
  | 메서드 | 시그니처 | 반환 | 비고 |
183
183
  |---|---|---|---|
@@ -193,7 +193,7 @@ export const posts = table('posts', {
193
193
  | `first` | `()` | `Promise<Rec \| undefined>` | 자동 `limit 1` |
194
194
  | `all` | `()` | `Promise<Rec[]>` | |
195
195
  | `paginate` | `(page, perPage)` | `Promise<PaginatedResult<Rec>>` | **체인 종단 페이지네이션**(결정 119) — `{ rows, total, page, pageCount, perPage }` 를 한 번에. count·rows 를 내부 계산(호출자 쿼리 한 번). page/perPage **클램프 내장**(0·음수·초과 = 마지막 페이지 · total 0 = pageCount 1). total=**number**. **result 통째로 render props 안전**(rows 는 Serialized 경계). `latest()` 타이브레이커(결정 110)로 페이지 경계 결정적. 집계 그룹(GroupChain)엔 없음 |
196
- | `count` | `()` | `Promise<bigint>` | driver 별 반환을 **bigint 로 통일** (E-4 (g) · `model.ts:390`) |
196
+ | `count` | `()` | `Promise<bigint>` | driver 별 반환을 **bigint 로 통일** (E-4 (g) · `model.ts`) |
197
197
  | `exists` | `()` | `Promise<boolean>` | |
198
198
  | `sum` · `avg` | `(col)` | `Promise<string \| null>` | numeric 정확성 · 대상 행 없으면 null |
199
199
  | `min` · `max` | `(col)` | `Promise<Row[col] \| null>` | 컬럼 타입 그대로 |
@@ -222,7 +222,7 @@ export const posts = table('posts', {
222
222
  > 테이블 Rec 으로 좁혀 "정답이 하나" 원칙을 지킨다 — 조인 테이블 컬럼까지
223
223
  > 필요한 SELECT 는 여전히 `Post.query()` 탈출구다.
224
224
 
225
- **루트 전용** (`ModelApi` · `model.ts:148-171`) — 체인 중간에서는 못 쓴다:
225
+ **루트 전용** (`ModelApi` · `model.ts`) — 체인 중간에서는 못 쓴다:
226
226
 
227
227
  | 메서드 | 시그니처 | 반환 | 비고 |
228
228
  |---|---|---|---|
@@ -256,7 +256,7 @@ export const posts = table('posts', {
256
256
  > 합계에서 **정밀도를 잃는**(lossy) breaking 이라 "어림 금지" 원칙에 어긋나 기각했다
257
257
  > (결정 91). 쓰기 개수(number)와 읽기 집계(string/bigint)는 축이 다르다.
258
258
 
259
- **레코드(`Rec`) 내장** (`model.ts:47-52`):
259
+ **레코드(`Rec`) 내장** (`model.ts`):
260
260
 
261
261
  - `rec.update(patch)` — `Partial<Row>` 부분 갱신, 갱신된 Rec 반환.
262
262
  - `rec.delete()` — id 기준 단건 삭제, `Promise<void>` (M2C). 벌크는
@@ -290,7 +290,7 @@ methods: {
290
290
  }
291
291
  ```
292
292
 
293
- **체인 상태 전이 주의** (`model.ts:112-146`):
293
+ **체인 상태 전이 주의** (`model.ts`):
294
294
 
295
295
  - `select()`·`include()` 이후에도 빌더 메서드(`where`·`orWhere`·
296
296
  `whereIn`·`orderBy`·`reorder`·`latest`·`limit`·`offset`)는 전부
@@ -314,7 +314,7 @@ methods: {
314
314
  > "…내부 구현은 Kysely 위에 얹으므로, 복잡한 쿼리는 언제든
315
315
  > `Post.query()`로 내려가 순수 쿼리 빌더를 쓸 수 있다(탈출구)."
316
316
 
317
- 실 구현 (E-4 (h) 정정 · `model.ts:165, 590-593`):
317
+ 실 구현 (E-4 (h) 정정 · `model.ts`):
318
318
 
319
319
  ```ts
320
320
  // 조인·집계·CTE 등 복잡 쿼리
@@ -331,10 +331,10 @@ const rows = await Post.query()
331
331
 
332
332
  두 타입은 이름이 비슷하지만 **별개**다 (E-4 §7).
333
333
 
334
- - `packages/vue/src/serialize.ts:28` — **`Serialized<T>`**: 임의 값의
334
+ - `packages/vue/src/serialize.ts` — **`Serialized<T>`**: 임의 값의
335
335
  JSON-safe 매핑 (Date → string, bigint → string, Hidden 브랜드 제외).
336
336
  `api()` 반환 타입 · `pageProps<'app:ctrl#action'>()` 결과 타입이 이것.
337
- - `packages/data/src/schema.ts:544` — **`SerializedOf<Defs>`**: 테이블
337
+ - `packages/data/src/schema.ts` — **`SerializedOf<Defs>`**: 테이블
338
338
  스키마 defs 의 SerializedOf — hidden 컬럼 키 제외한 Row.
339
339
 
340
340
  혼동 유발이라 이름 분리를 유지한다 (E-4 (i) 결정).
@@ -370,11 +370,11 @@ const rows = await Post.query()
370
370
  안전하고, 재시도·보정은 잡/아웃박스로 다룬다.
371
371
 
372
372
  ```ts
373
- // domain/services/PlaceOrder.ts — main 커밋 성공 뒤에만 analytics 기록
373
+ // domain/services/placeOrder.ts — main 커밋 성공 뒤에만 analytics 기록 (파일명 camelCase)
374
374
  import { service, afterCommit } from 'gaonjs/service'
375
375
  import { getConnection } from 'gaonjs/data'
376
376
 
377
- export const placeOrder = service(async (input: { userId: string; total: number }) => {
377
+ export const PlaceOrder = service(async (input: { userId: string; total: number }) => {
378
378
  const order = await Order.create({ userId: input.userId, total: input.total }) // main 트랜잭션
379
379
 
380
380
  // 커넥션을 가로지르는 쓰기는 트랜잭션 밖 — 커밋 성공 뒤에만.
@@ -394,7 +394,7 @@ export const placeOrder = service(async (input: { userId: string; total: number
394
394
  - **왜 `afterCommit`** — main 이 롤백되면 analytics 기록도 일어나지 않아야 한다.
395
395
  `afterCommit` 은 커밋이 성공한 경우에만 콜백을 돈다(§9 · `agents/async.md`).
396
396
 
397
- ### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts:601-622`)
397
+ ### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
398
398
 
399
399
  `model()` 은 스키마(§1)를 Kysely 위의 실행 가능한 API 로 감싼다 —
400
400
  scope·인스턴스 메서드를 함께 선언하고, 나머지 체이닝(§4)·CRUD 는
@@ -443,7 +443,7 @@ await post.publish() // 인스턴스 메서
443
443
  - **두 번째 인자** = `{ scopes?, methods?, hooks? }`. `scopes` 값은
444
444
  `(q) => q.where(...)` 형태로 쿼리를 좁히는 함수, `methods` 는 인스턴스
445
445
  메서드(`this` 로 레코드 필드·관계에 접근), `hooks` 는 `beforeCreate`
446
- 하나 (`model.ts:610`).
446
+ 하나 (`model.ts`).
447
447
  - **스코프는 반드시 `scopes: {}` 객체 안에** 선언한다 (NAMESPACED) —
448
448
  model() 밖 별도 함수·프로퍼티로 흉내내지 않는다.
449
449
  - **파라미터 스코프** (M2C · 결정 31) — 첫 인자 `q` 는 고정, 그 뒤
@@ -95,7 +95,7 @@ async function search(q: string) {
95
95
 
96
96
  - **시그니처** — `api(key, params?, opts?)`. 제네릭 타입 인자를 직접
97
97
  붙이지 않는다 — `key` 값 자체가 `keyof GaonRouteMap` 으로 좁혀져
98
- 반환 타입을 결정한다 (`packages/vue/src/api.ts:72`).
98
+ 반환 타입을 결정한다 (`packages/vue/src/api.ts`).
99
99
  - **params** — 라우트에 `:id` 같은 자리표시자가 있으면 거기서 채우고,
100
100
  남는 값은 GET 이면 쿼리스트링, 그 외 메서드는 JSON 본문으로 실린다
101
101
  (서버 `this.params` 우선순위와 대칭 · `agents/web.md` §3).
@@ -183,4 +183,6 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
183
183
  - **결정 124** — §3.1 개정: **app-side 정적 주입**(변수 동적 import 폐기) · **wasm 표면 은닉**(불투명 함수 · domain/ua/path 를 wasm 이 확보 · 미끼 시크릿 내장) · **WS 클라 봉인**(`setWsFrameCodec`) · **seal 앱 한정 CSP** · doctor `seal-security` main.ts 배선 검사 + `seal-client-wiring` fixer · 실 브라우저 e2e 게이트 · 부수 정정(`.wasm` MIME · `session.csrf` forwarding).
184
184
  - **결정 125** — **Inertia 네비게이션 평문 P0** 수정: 봉인 대상 판별기(`isSealTarget`)에 `X-Inertia: true` 를 편입. Inertia GET 방문은 `Accept: text/html` 로 와 application/json 이 없어 자동 면제되던 탓에 응답 props 가 평문으로 새어나갔다(클라 인터셉터는 시그널을 붙였으나 서버가 봉인 안 함). 네비게이션 봉인 e2e 를 seal blocking 게이트에 편입(실 vite+chromium · wire 봉인/`?q=` 왕복 단언).
185
185
  - **결정 222** — **클라 WS 수신 fail-open P1** 수정: 클라 `wsDecode`(client.ts)가 `P:` 평문·무prefix 프레임을 throw 없이 원문 통과시켜, 서버는 requireDecrypt 로 거부하는데 클라만 주입된 평문을 소비하던 봉인 파괴. `wsDecode` 를 서버 `SealWsTerminator` 와 대칭으로 만들어 `E:` 만 개봉·`P:`/무prefix 거부. `useChannel` 은 개봉 실패를 조용히 드롭하지 않고 소켓을 **4500 종료**(서버 대칭) + 콘솔 명시 + 재연결 안 함.
186
- - **결정 223** — **HTTP replay Redis 없으면 조용히 off + 허위 주석 P1** 수정: `normalizeSealConfig` 이 nonceStore 없으면 `replay=null` 로 두어 nonce 검사가 사라지고 drift(±60s)만 남아 60초 내 재전송이 통과했다(`sealBridge` 주석은 "in-memory 폴백" 이라 거짓 단언 — `MemoryNonceStore` 는 export 만·미배선). HTTP replay 를 **항상 배선**한다 — Redis 있으면 재사용(멀티 인스턴스 안전), 없으면 in-memory 폴백(단일 인스턴스 전용) + 부팅 경고. **기각: 부팅 throw(옵션 A)** — 기본 배포가 워커 1(CLAUDE 규칙 6)이라 단일 인스턴스 in-memory 가 정상 경로인데 throw 는 dev·단일 인스턴스 seal 앱을 깨고 문서(§4 "in-memory dev/단일/테스트")와 상충. 폴백+경고가 비파괴적·정본 정합.
186
+ - **결정 223** — **HTTP replay Redis 없으면 조용히 off + 허위 주석 P1** 수정: `normalizeSealConfig` 이 nonceStore 없으면 `replay=null` 로 두어 nonce 검사가 사라지고 drift(±60s)만 남아 60초 내 재전송이 통과했다(`sealBridge` 주석은 "in-memory 폴백" 이라 거짓 단언 — `MemoryNonceStore` 는 export 만·미배선). HTTP replay 를 **항상 배선**한다 — Redis 있으면 재사용(멀티 인스턴스 안전), 없으면 in-memory 폴백(단일 인스턴스 전용) + 부팅 경고. **기각: 부팅 throw(옵션 A)** — 기본 배포가 워커 1(CLAUDE 규칙 6)이라 단일 인스턴스 in-memory 가 정상 경로인데 throw 는 dev·단일 인스턴스 seal 앱을 깨고 문서(§4 "in-memory 폴백 = 단일 인스턴스 전용")와 상충. 폴백+경고가 비파괴적·정본 정합.
187
+ - **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
188
+ - **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
@@ -29,7 +29,7 @@
29
29
  `Referrer-Policy: strict-origin-when-cross-origin`·`Cross-Origin-Opener-Policy`
30
30
  를 자동으로 붙인다. 기본 CSP 는 Inertia SPA + vite 스택에서 안 깨지게 튜닝돼
31
31
  있다(`script-src 'self'`·인라인 스타일 허용·`connect-src ... ws: wss:` 로
32
- realtime 허용). 끄거나 조정은 `createApp({ security: { securityHeaders: … } })`
32
+ realtime 허용). 끄거나 조정은 `gaon.config.ts` 의 `web.security.securityHeaders`
33
33
  — `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
34
34
  `helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
35
35
  - **스토리지 오리진 CSP 자동 배선 (결정 131)** — `gaon.config.ts` 의 storage(S3/R2/MinIO)
@@ -182,8 +182,8 @@
182
182
 
183
183
  ```ts
184
184
  // 시크릿 — env 로만 (gaonjs/env)
185
- import { getEnvVar } from 'gaonjs/env'
186
- const apiKey = getEnvVar('PAYMENT_API_KEY') // .env / 배포 환경변수
185
+ import { env } from 'gaonjs/env'
186
+ const apiKey = env('PAYMENT_API_KEY') // .env / 배포 환경변수
187
187
 
188
188
  // raw SQL 값은 바인딩 위치에만
189
189
  import { sql } from 'kysely'
@@ -21,7 +21,7 @@
21
21
  - `gaon test` 가 테스트 전용 데이터베이스(`<db>_test`)를 **자동 준비**한다
22
22
  — 없으면 만들고(CREATE DATABASE) 마이그레이션까지 적용한 뒤 vitest 를
23
23
  돌린다. 스캐폴드 `test/setup.ts` 가 그 DB 에 붙고(`connectTestDatabase`)
24
- 매 테스트 뒤 전 테이블을 비운다(`truncateAll`). 아래 §5 참고.
24
+ 매 테스트 뒤 전 커넥션의 테이블을 비운다(`truncateAllConnections`). 아래 §5 참고.
25
25
  - `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
26
26
  테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
27
27
  `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 같은 접두가 **NATS KV 버킷**
@@ -282,6 +282,9 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
282
282
  | 결정 215 (13차 W3) | `expectEventProcessed` 가 trigger 뒤 아웃박스 1회 자동 드레인 — `service()` 경유 emit(결정 144 스테이징)도 같은 헬퍼로 확증(§4.1) · 명시 탈출구 `drainOutbox()` |
283
283
  | 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
284
284
  | 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
285
+ | 결정 137 | `truncateAllConnections()` — 등록된 **모든** 커넥션 순회 격리(멀티 커넥션 §4.5 · 스캐폴드 `test/setup.ts` 기본) |
286
+ | 결정 136 | `connectTestDatabase` 가 스토리지·메일 하네스도 배선(버킷 `<bucket>-test`·MailPit) — 배터리 테스트가 실 인프라로 격리 |
287
+ | 결정 249 | `connectTestDatabase` 가 락·캐시 백엔드도 in-memory 로 배선 — `lock()`·`cache()` 쓰는 잡·서비스가 `gaon test` 에서도 그대로(이전 `LockBackendUnconfiguredError` 해소 · §5) |
285
288
  | 결정 130 | `gaon test` 가 NATS 스트림도 자동 격리(`GAON_STREAM_PREFIX` → `GAON_TEST_JOBS`·`test.gaon.jobs.>`) — 개발 워커 병행 시 잡 누출 방지(규칙 10 이행) |
286
289
  | 결정 203 | 같은 접두를 **NATS KV 버킷**에도 적용(`gaon_lease`·프레즌스·허브 → `test_gaon_lease` 등) — 스트림만 격리하던 결정 130 의 빈틈(KV 미격리)을 메움. 스케줄러 리스 경합·크로스-프리픽스 KV 누출 방지 |
287
290
  | §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
@@ -24,7 +24,7 @@ export default controller({
24
24
 
25
25
  // JSON 액션 — 객체를 반환하면 JSON 응답
26
26
  async search() {
27
- const { q } = this.params({ _row: {} as { q: string } }) // 검증 실패 자동 422 JSON
27
+ const { q } = this.params({ _row: {} as { q: string } }) // 타입만 · 런타임 검증 없음(스키마 파생 폼과 달리)
28
28
  return { results: await Post.published().search(q).limit(10).all() }
29
29
  },
30
30
  })
@@ -506,7 +506,7 @@ import { SendWelcomeMail } from '../../../domain/jobs/sendWelcomeMail.js'
506
506
 
507
507
  export default controller({
508
508
  async new() {
509
- return this.render('Auth/Register', {})
509
+ return this.render('Auth/Signup', {})
510
510
  },
511
511
  async create() {
512
512
  const { name, email, password } = this.params({
package/dist/work.js CHANGED
@@ -75,6 +75,10 @@ export async function runWorkCommand(opts = {}) {
75
75
  // catch(index.ts)가 exitCode=1 만 세팅한 채 프로세스가 매달린다. 부팅 실패는 그때까지 연
76
76
  // 핸들을 정리하고 명시 종료한다(serve 와 대칭). 정상 부팅·정상 종료엔 영향 없다.
77
77
  // (이전엔 runWork 실패만 정리했고, 그 앞의 wireDomain·loadDomain throw 는 nats 를 누출했다.)
78
+ // 결정 252: wireDomain 이 **mid-wire** 로 throw 하면(예: Redis 배선 후 다음 단계 실패)
79
+ // domainWiring 이 undefined 라 아래 catch 의 closeDomain 이 안 돌지만, wireDomain 이 자기
80
+ // 부분 핸들(Redis·DB)을 스스로 정리하고 rethrow 하므로(opener 소유) 누출이 없다 — nats 는
81
+ // 이 catch 가 닫는다. 이로써 serve(직접 exit)와 work(nats close + exit)가 mid-wire 대칭.
78
82
  let domainWiring;
79
83
  let work;
80
84
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.41.3",
3
+ "version": "0.41.5",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,12 +28,12 @@
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
30
  "@gaonjs/async": "0.15.0",
31
- "@gaonjs/mail": "0.3.0",
32
- "@gaonjs/data": "0.17.0",
33
31
  "@gaonjs/core": "0.2.3",
34
- "@gaonjs/i18n": "0.2.2",
32
+ "@gaonjs/config": "0.17.4",
33
+ "@gaonjs/data": "0.17.0",
35
34
  "@gaonjs/web": "0.19.4",
36
- "@gaonjs/config": "0.17.3"
35
+ "@gaonjs/i18n": "0.2.2",
36
+ "@gaonjs/mail": "0.3.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""