@gaonjs/cli 0.42.0 → 0.42.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.
- package/dist/commands/test.js +17 -7
- package/dist/templates/project/AGENTS.md.tpl +10 -6
- package/dist/templates/project/agents/async.md.tpl +18 -8
- package/dist/templates/project/agents/data.md.tpl +23 -13
- package/dist/templates/project/agents/frontend.md.tpl +28 -0
- package/dist/templates/project/agents/i18n.md.tpl +31 -11
- package/dist/templates/project/agents/mail.md.tpl +6 -4
- package/dist/templates/project/agents/realtime.md.tpl +25 -14
- package/dist/templates/project/agents/seal.md.tpl +37 -9
- package/dist/templates/project/agents/security.md.tpl +15 -0
- package/dist/templates/project/agents/storage.md.tpl +23 -6
- package/dist/templates/project/agents/testing.md.tpl +14 -2
- package/dist/templates/project/agents/web.md.tpl +22 -6
- package/dist/templates/project/package.json.tpl +1 -1
- package/package.json +7 -7
package/dist/commands/test.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* @gaonjs/cli · `gaon test` — vitest wrapper (M9-G · v0.15 §13.5)
|
|
3
3
|
*
|
|
4
4
|
* The One Way — 하나의 명령이 vitest 를 얇게 감싼다. 사용자는
|
|
5
|
-
* `gaon test` 로 전체를, `gaon test posts` 로 필터를, `gaon test --unit`
|
|
5
|
+
* `gaon test` 로 전체를, `gaon test posts` 로 필터를, `gaon test --scope unit`
|
|
6
6
|
* 으로 단위만 실행한다. 나머지는 전부 vitest 에 그대로 위임 — 우리가
|
|
7
7
|
* 관례를 재발명하지 않는다.
|
|
8
8
|
*
|
|
9
|
-
* 스코프 필터(§9 실 인프라 관례):
|
|
10
|
-
* --unit *.test.ts (통합 제외)
|
|
11
|
-
* --integration *.integration.test.ts 만
|
|
12
|
-
* (기본)
|
|
9
|
+
* 스코프 필터(§9 실 인프라 관례 · `--scope <값>`):
|
|
10
|
+
* --scope unit *.test.ts (통합 제외)
|
|
11
|
+
* --scope integration *.integration.test.ts 만
|
|
12
|
+
* --scope all / (기본) 둘 다 실행
|
|
13
13
|
*
|
|
14
14
|
* 실행 경로 우선순위:
|
|
15
15
|
* 1) 사용자 package.json 의 `test` 스크립트가 있으면 `<pm> run test`(결정 170 ·
|
|
@@ -165,7 +165,11 @@ export async function runTestCommand(args = [], opts = {}) {
|
|
|
165
165
|
const passthrough = [...scopeExtras, ...args];
|
|
166
166
|
let cmd;
|
|
167
167
|
let spawnArgs;
|
|
168
|
-
|
|
168
|
+
// 결정 271(F5): 스캐폴드 `"test": "gaon test"` 가 정본이라, gaon test 가 그 스크립트를
|
|
169
|
+
// 다시 실행하면 무한 재귀다. 재진입(GAON_TEST_ACTIVE) 이면 사용자 스크립트를 건너뛰고
|
|
170
|
+
// 내장 vitest 경로로 폴백해 재귀를 끊는다 — 프로비저닝·프리픽스는 위/아래에서 유지된다.
|
|
171
|
+
const reentrant = process.env.GAON_TEST_ACTIVE === '1';
|
|
172
|
+
if (hasTestScript(cwd) && !reentrant) {
|
|
169
173
|
// 결정 170 W1: pnpm 하드코딩 대신 프로젝트 선언 pm 으로 test 스크립트 실행
|
|
170
174
|
// (npm/yarn 스캐폴드 대응 · 공유 `pm.ts` 단일 소스). passthrough(필터 등)는
|
|
171
175
|
// pnpm·npm 은 `--` 로, yarn(classic)은 `--` 없이 전달 — scriptRunArgs 가 처리.
|
|
@@ -194,7 +198,13 @@ export async function runTestCommand(args = [], opts = {}) {
|
|
|
194
198
|
// 붙으면 테스트가 쓰는 스트림/subject 가 `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로
|
|
195
199
|
// 갈려, 개발용 `gaon work` 가 떠 있어도 서로 잡을 훔치지 않는다. 사용자가 이미
|
|
196
200
|
// 값을 세팅했으면 존중한다(고급 · 다중 테스트 컨텍스트 분리).
|
|
197
|
-
|
|
201
|
+
// GAON_TEST_ACTIVE: 자식(사용자 test 스크립트가 `gaon test` 여도)이 재진입을 감지해
|
|
202
|
+
// 사용자 스크립트를 재실행하지 않고 내장 vitest 로 가도록 하는 재귀 차단 센티넬(결정 271).
|
|
203
|
+
const testEnv = {
|
|
204
|
+
...process.env,
|
|
205
|
+
GAON_STREAM_PREFIX: process.env.GAON_STREAM_PREFIX ?? 'test',
|
|
206
|
+
GAON_TEST_ACTIVE: '1',
|
|
207
|
+
};
|
|
198
208
|
if (json) {
|
|
199
209
|
writeOut(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope, streamPrefix: testEnv.GAON_STREAM_PREFIX }) + '\n');
|
|
200
210
|
}
|
|
@@ -76,9 +76,11 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
76
76
|
`router.delete(...)` 등). REST + `fetch()` 는 **API 앱(JWT) 전용**.
|
|
77
77
|
7. **한 액션은 한 종류 응답만** (render 또는 JSON 또는 redirect —
|
|
78
78
|
혼용 금지 · doctor response-mixing).
|
|
79
|
-
8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`
|
|
80
|
-
|
|
81
|
-
`
|
|
79
|
+
8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`routes.manifest.ts`
|
|
80
|
+
(routes 축 = 타입 브리지 + api() 런타임 매니페스트 2파일 · 결정 127)·
|
|
81
|
+
`tables.d.ts`·`messages.d.ts`(3축 · `locales/` 있을 때 · 결정 158)·`env.d.ts`
|
|
82
|
+
(`.env` 스캔 · 프론트 앱 · 결정 198) 는 `gaon check`/`gaon dev`/`gaon gen` 이
|
|
83
|
+
재생성한다.
|
|
82
84
|
|
|
83
85
|
### 2.1 파일 네이밍 표 (2026-07-24 승인 · 벤치마크 R1 실측 고정)
|
|
84
86
|
|
|
@@ -113,7 +115,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
113
115
|
|
|
114
116
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
115
117
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
116
|
-
3. `dependency-direction` — 의존 방향 4규칙 위반
|
|
118
|
+
3. `dependency-direction` — 의존 방향 4규칙 위반 (domain→shared 를 값으로 import 한 경우만 `--fix` 지원 = `import`→`import type` AST 삽입 · 나머지 방향(domain→app·app→app·shared→app)은 파일 위치 판단이 필요해 수동 · `doctor/fixers/dependency-direction`)
|
|
117
119
|
4. `connections` — 스키마·`getConnection` 이 쓰는 커넥션 키가 `gaon.config.ts` 에 등록됐는지 · db 설정 정적 분석(삼항·`??` 지원 · 못 읽으면 안내) (§4.5 · 결정 135)
|
|
118
120
|
5. `migration-diff` — 스키마 vs DB 상태 불일치
|
|
119
121
|
6. `shared-purity` — shared/ 전체(.ts·.vue)가 `api`/`pageProps`·domain 값을 import (컴포저블·컴포넌트 통일 · 결정 25·217)
|
|
@@ -134,7 +136,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
134
136
|
21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
|
|
135
137
|
22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
|
|
136
138
|
23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
|
|
137
|
-
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon
|
|
139
|
+
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon doctor --fix --yes` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
138
140
|
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
|
|
139
141
|
26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
|
|
140
142
|
27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
|
|
@@ -208,7 +210,8 @@ gaon doctor # 정적 검사 27종 (§2.2)
|
|
|
208
210
|
| `gaon new <name>` | 프로젝트 스캐폴드 |
|
|
209
211
|
| `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**serve·work·hub 자동 기동**·**코드 변경 감시·재시작** · 결정 211) |
|
|
210
212
|
| `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
|
|
211
|
-
| `gaon g <type> <name>` | 스캐폴드: `auth`·`controller`·`model`·`page`·`job` |
|
|
213
|
+
| `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` · `g auth --app <앱> --public` = 비-web 앱에 공개 회원가입(`/registration/new`)을 opt-in(기본: web=공개·비-web=역할 게이트 · 결정 155) |
|
|
214
|
+
| `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
|
|
212
215
|
| `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
|
|
213
216
|
| `gaon check` / `test` / `doctor` | 검증 루프 |
|
|
214
217
|
| `gaon console` | 프로젝트 컨텍스트 REPL |
|
|
@@ -239,6 +242,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
|
|
|
239
242
|
| 패키지 | 역할 |
|
|
240
243
|
|---|---|
|
|
241
244
|
| `gaonjs` | 파사드(설치 단위) · CLI `gaon` |
|
|
245
|
+
| `create-gaon` | `npm create gaon <name>` 진입점 · `gaon new` 에 위임 |
|
|
242
246
|
| `@gaonjs/cli` | 제너레이터·스캐폴딩·명령 라우팅 |
|
|
243
247
|
| `@gaonjs/data` | 스키마 DSL · 모델 · 마이그레이션 |
|
|
244
248
|
| `@gaonjs/config` | `gaon.config.ts`·`app.config.ts` |
|
|
@@ -79,9 +79,16 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
|
|
|
79
79
|
- **시그니처** — `job(handler, options?)`. 첫 인자는 평범한 async
|
|
80
80
|
함수(`(...args) => Promise<void> | void`) — `defineJob` 이나
|
|
81
81
|
`{ perform }` 객체 형태가 아니다.
|
|
82
|
-
- **이름** — `
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
- **이름** — `job()` 이 **정의 시점에 스스로** 이름을 잡는다: `options.name` 이
|
|
83
|
+
있으면 그것, 없으면 자신을 정의한 **파일의 파일명**을 스택에서 유추한다
|
|
84
|
+
(`inferNameFromCaller`). 그래서 `domain/jobs/` 파일에 두기만 하면 로더를 기다리지
|
|
85
|
+
않고 곧바로 `.later()` 를 호출할 수 있다. `domain/jobs/` 파일 로더의 `assignName`
|
|
86
|
+
은 폴백일 뿐(이미 이름이 있으면 멱등). 스택에서 파일명을 못 얻는 특수 환경에서만
|
|
87
|
+
`options.name` 을 명시한다(이름을 못 얻은 채 발행하면 에러).
|
|
88
|
+
- **파일당 잡 하나(결정 271)** — 같은 파일에 `job()` 을 둘 이상 두면 파일명
|
|
89
|
+
유추가 충돌한다. 이제 **등록 시 throw**(조용한 덮어쓰기 = 발행이 엉뚱한
|
|
90
|
+
핸들러로 가던 무신호 버그 봉합) — 각각 `name` 을 다르게 주거나 파일을 나눈다.
|
|
91
|
+
리스너(`on()`)도 동형 — 같은 파일에 여럿 두면 `id` 를 다르게 준다(durable 충돌).
|
|
85
92
|
- **옵션** — `queue`(기본 `'default'`) · `retries`(기본 3) ·
|
|
86
93
|
`curve`(백오프 곡선 ms) · `jitter` · `concurrency`.
|
|
87
94
|
- **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
|
|
@@ -108,7 +115,7 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
|
|
|
108
115
|
|
|
109
116
|
```ts
|
|
110
117
|
async create() {
|
|
111
|
-
const user = await User.create(this.params(User.
|
|
118
|
+
const user = await User.create(this.params(User.form))
|
|
112
119
|
await SendWelcomeMail.later(user.id) // 컨트롤러에서 발행
|
|
113
120
|
return this.redirect('/dashboard')
|
|
114
121
|
}
|
|
@@ -204,9 +211,10 @@ export const PlaceOrder = service(async (input: { name: string }) => {
|
|
|
204
211
|
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
|
|
205
212
|
기동 시 보장된다(결정 144 · nats 설정이 있을 때).
|
|
206
213
|
- 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
|
|
207
|
-
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `
|
|
208
|
-
`outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(
|
|
209
|
-
`docs/guides/operations.md`). 미발행 행은
|
|
214
|
+
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `runWork()` 의
|
|
215
|
+
프로그래매틱 옵션 `outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(`gaon
|
|
216
|
+
work` CLI 플래그가 아니다 · 운영 상세는 `docs/guides/operations.md`). 미발행 행은
|
|
217
|
+
절대 삭제되지 않는다.
|
|
210
218
|
|
|
211
219
|
### 5. 스케줄러
|
|
212
220
|
|
|
@@ -368,7 +376,9 @@ export const SendWelcomeMail = job(async (userId: bigint) => {
|
|
|
368
376
|
```ts
|
|
369
377
|
// apps/web/controllers/registration.ts — 컨트롤러는 잡 발행만 (직접 발송 금지)
|
|
370
378
|
async create() {
|
|
371
|
-
|
|
379
|
+
// 서비스는 모델 폼이 없다 — 컨트롤러가 모델 스키마 폼(`User.form.pick(...)`)으로
|
|
380
|
+
// 검증한 입력을 서비스에 넘긴다(폼 표면 = 모델 소속 · 결정 104).
|
|
381
|
+
const user = await RegisterUser.call(this.params(User.form.pick('name', 'email')))
|
|
372
382
|
await SendWelcomeMail.later(user.id)
|
|
373
383
|
return this.redirect('/dashboard')
|
|
374
384
|
}
|
|
@@ -168,7 +168,7 @@ export const posts = table('posts', {
|
|
|
168
168
|
### 4. 체이닝 전체 (`packages/data/src/model.ts`)
|
|
169
169
|
|
|
170
170
|
체이닝 표면은 아래 표가 **전부**다. 표에 없는 메서드
|
|
171
|
-
(`destroy`·`findBy`·`
|
|
171
|
+
(`destroy`·`findBy`·`order`·해시 인자
|
|
172
172
|
`where({...})` 같은 다른 ORM 관습)를 추측해서 쓰지 말 것 — 표
|
|
173
173
|
바깥의 쿼리는 §5 `Post.query()` 탈출구로 내려간다.
|
|
174
174
|
|
|
@@ -185,7 +185,7 @@ export const posts = table('posts', {
|
|
|
185
185
|
| 메서드 | 시그니처 | 반환 | 비고 |
|
|
186
186
|
|---|---|---|---|
|
|
187
187
|
| `where` | `(col, op, val?)` | `Chain` | op 에 따라 val 형태 강제 (위 표) |
|
|
188
|
-
| `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)`
|
|
188
|
+
| `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)` 축약. **빈 배열(`[]`)은 단락**(결정 99) — 단독이면 DB 무접촉으로 빈 결과(`count`=0·`exists`=false), 다른 조건과 `or` 로 섞이면 `1 = 0` 으로 상수 폴딩(`in ()` 문법 오류 방지) |
|
|
189
189
|
| `whereAny` | `(cols, op, val?)` | `Chain` | **여러 컬럼에 같은 조건을 OR 로 묶어 괄호로 감쌈** = `(c1 op v OR c2 op v)` · 앞선 where 와 **AND 로 안전 결합**(결정 118). 다중 컬럼 검색의 정본 — `orWhere` 로 흩뜨리면 앞 조건이 샌다(아래 함정) |
|
|
190
190
|
| `orWhere` | `(col, op, val?)` | `Chain` | op 12종 전부 (M2C) · 결합은 `(a AND b) OR c` (Rails 관습). **다중 컬럼 검색엔 쓰지 말 것** — `whereAny` 를 쓴다(결정 118) |
|
|
191
191
|
| `orderBy` | `(col, dir?)` | `Chain` | dir 기본 `'asc'` · 호출마다 누적 (다중 정렬) · 정렬 뒤 PK 타이브레이커 자동 부가(결정 110) |
|
|
@@ -203,9 +203,9 @@ export const posts = table('posts', {
|
|
|
203
203
|
| `pluck` | `(col)` | `Promise<Row[col][]>` | 단일 컬럼 배열 · 정렬·limit·offset 반영 |
|
|
204
204
|
| `select` | `(['a', 'b'])` | `SelectChain<Row, K>` | 부분 컬럼 — `first`/`all` 이 `Pick<Row, K>` **plain 행** 반환 (메서드·관계·update 없음) |
|
|
205
205
|
| `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
|
|
206
|
-
| `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
|
|
206
|
+
| `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy·**distinct** 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
|
|
207
207
|
| `deleteAll` | `()` | `Promise<number>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
|
|
208
|
-
| `incrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 증가**(결정 115) — `SET col = col + by` 한 문장 · 수치 컬럼 · 영향 행 수(number). 벌크 계약(limit/offset/orderBy 있으면 throw)은 updateAll 과 동일 |
|
|
208
|
+
| `incrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 증가**(결정 115) — `SET col = col + by` 한 문장 · 수치 컬럼 · 영향 행 수(number). 벌크 계약(limit/offset/orderBy/distinct 있으면 throw)은 updateAll 과 동일 |
|
|
209
209
|
| `decrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 감소**(결정 115) — `SET col = col - by`. incrementAll 의 대칭 |
|
|
210
210
|
|
|
211
211
|
**집계·조인 그룹** (`Chain` · M2E · 결정 34):
|
|
@@ -214,7 +214,7 @@ export const posts = table('posts', {
|
|
|
214
214
|
|---|---|---|---|
|
|
215
215
|
| `groupBy` | `(col \| col[])` | `GroupChain` | 그룹 집계로 **분기** — 종단은 집계 함수 하나(`count`/`sum`/`avg`/`min`/`max`)이고 결과는 `Rec[]` 이 아니라 **`그룹 키 + 집계값` 행 배열**(`GroupRow[]`). `Post.groupBy('authorId').count()` → `{ authorId, count: bigint }[]` |
|
|
216
216
|
| `having` | `('count', op, val)` · `('sum'\|'avg'\|'min'\|'max', col, op, val)` | `GroupChain` | groupBy 뒤 **집계값** 필터 (그룹 키 필터는 `where`). `.having('count', '>', 2)` · `.having('sum', 'price', '>=', 1000)` |
|
|
217
|
-
| `distinct` | `()` · `(col \| col[])` | `Chain` · `SelectChain` | 인자 없으면 `SELECT DISTINCT` 전체 행(
|
|
217
|
+
| `distinct` | `()` · `(col \| col[])` | `Chain` · `SelectChain` | 인자 없으면 `SELECT DISTINCT` 전체 행(읽기 집계 이어짐), 컬럼을 주면 그 컬럼만 뽑는 `SelectChain`. `distinct().count()` 는 `count(distinct id)`. **벌크 쓰기로는 이어지지 않는다** — distinct 걸린 체인의 `updateAll`/`deleteAll` 등은 throw(위 벌크 계약) |
|
|
218
218
|
| `withCount` | `(...rels)` | `IncludedChain` | 관계별 개수를 **상관 서브쿼리**로 얹는다 — `withCount('comments')` → 각 Rec 에 `commentsCount: bigint`. 조인이 아니라 행이 안 늘어 `limit` 과 함께 써도 개수가 정확. **hasMany·hasOne·belongsToMany 만**(belongsTo 는 항상 0/1 이라 throw). `include` 와 같은 체인에 실린다(`include('author').withCount('comments')`) |
|
|
219
219
|
| `join` | `(table, 'table.col', 'self.col')` | `JoinChain` | INNER JOIN — **필터·정렬 수단**이고 반환은 **자기 테이블의 Rec**(조인 테이블 컬럼은 안 실림 → 뽑아야 하면 §5 `Post.query()`). **자기 테이블 컬럼은 한정 없이 그대로** 쓴다 — `t.timestamps()`·`t.id()` 로 양 테이블이 `createdAt`·`id` 를 공유해도 조인 시 자기 테이블로 자동 한정돼 `where('createdAt', ..)` 가 안전하다(ambiguous column 방지). **조인 테이블** 조건만 한정 이름(`where('users.name', '=', ...)`)으로 쓴다. 1:N 부풀림은 `distinct()` 로 접는다. `join`/`leftJoin`·`where`·`orderBy`·`distinct`·`select`·`pluck`·`count`·`exists`·`first`·`all` 이어짐 |
|
|
220
220
|
| `leftJoin` | `(table, 'table.col', 'self.col')` | `JoinChain` | LEFT OUTER JOIN — 짝 없는 자기 행도 남는다. "짝 없는 것만" = `.where('posts.id', 'is null')` |
|
|
@@ -304,7 +304,8 @@ methods: {
|
|
|
304
304
|
- `groupBy` 이후는 `GroupChain` — 결과가 그룹 행이라 `first`/`all` 대신
|
|
305
305
|
집계 함수가 종단이고, 레코드가 아니라 `include`·`select` 도 없다.
|
|
306
306
|
- `join`/`leftJoin` 이후는 `JoinChain` — 반환은 자기 Rec 이라 `include`·집계 그룹은
|
|
307
|
-
없지만 `where`/`whereAny`/`orWhere`/`orderBy`/`distinct`/`limit`/`offset`·스칼라
|
|
307
|
+
없지만 `where`/`whereAny`/`orWhere`/`orderBy`/`distinct`/`limit`/`offset`·스칼라 집계는
|
|
308
|
+
**`count`·`exists` 두 종만**(sum/avg/min/max 는 JoinChain 에 없다 — 필요하면 §5 `Post.query()`)·
|
|
308
309
|
`select`(자기 컬럼)·`pluck`·`first`/`all`/**`paginate`** 는 이어진다. 그래서 **텍스트
|
|
309
310
|
검색(whereAny)+관계 필터(join)+페이지네이션을 한 체인으로** 조립할 수 있다(읽기 조합).
|
|
310
311
|
- `select()` 이후엔 `include` 도 없다 (부분 행에 관계를 붙이지 않는다).
|
|
@@ -725,7 +726,7 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
725
726
|
```ts
|
|
726
727
|
// domain/seed.ts
|
|
727
728
|
import { seed } from 'gaonjs/data'
|
|
728
|
-
import { User } from './models/User'
|
|
729
|
+
import { User } from './models/User.js'
|
|
729
730
|
|
|
730
731
|
export default seed(async () => {
|
|
731
732
|
await User.create({ email: 'admin@example.com', name: 'Admin' })
|
|
@@ -800,8 +801,8 @@ const titles = await Post.pluck('title') // string[]
|
|
|
800
801
|
const slim = await Post.select(['id', 'title']).all() // Pick<Row, 'id' | 'title'>[]
|
|
801
802
|
|
|
802
803
|
// 삭제 — 단건은 레코드, 벌크는 deleteAll (M2C)
|
|
803
|
-
const
|
|
804
|
-
await
|
|
804
|
+
const doomed = await Post.find(id)
|
|
805
|
+
await doomed.delete()
|
|
805
806
|
const removed = await Post.where('published', '=', false).deleteAll() // number
|
|
806
807
|
const touched = await Post.where('authorId', '=', me.id).updateAll({ published: true })
|
|
807
808
|
|
|
@@ -815,15 +816,20 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
815
816
|
|
|
816
817
|
## 알려진 함정
|
|
817
818
|
|
|
818
|
-
- **체이닝 표 밖 메서드 추측 금지** — `destroy`·`findBy
|
|
819
|
-
`order`·해시 인자 `where({...})` 는
|
|
820
|
-
벌크는 `deleteAll()` (결정 31).
|
|
819
|
+
- **체이닝 표 밖 메서드 추측 금지** — `destroy`·`findBy`·
|
|
820
|
+
`order`·해시 인자 `where({...})` 는 없다(페이지네이션은 표 안 `paginate`
|
|
821
|
+
· 결정 119). 단건 삭제는 `rec.delete()`, 벌크는 `deleteAll()` (결정 31).
|
|
821
822
|
- **`find(id)` 는 없으면 throw** — undefined 를 원하면
|
|
822
823
|
`where('id', '=', id).first()`.
|
|
824
|
+
- **`find` 과 `where('id', …)` 의 id 타입은 비대칭** — `find(id)` 는
|
|
825
|
+
`bigint | string` 을 받는다(문자열은 그대로 드라이버에 넘긴다 · 별도 코드에서
|
|
826
|
+
`BigInt()` 강제 안 함). 반면 `where('id', '=', v)` 는 컬럼 타입 `Row['id']`(=bigint)를
|
|
827
|
+
요구해 **문자열을 넘기면 컴파일 에러**다. 라우트 파라미터(문자열)로 조회할 땐
|
|
828
|
+
`find(this.params.id)` 를 쓰거나, `where` 로는 `BigInt(this.params.id)` 로 감싼다.
|
|
823
829
|
- **관계 대상은 문자열 테이블명** — 모델 객체를 넘기면 순환 참조.
|
|
824
830
|
- **불규칙 복수**(`people`·`media` 등)는 단수화 관례가 못 잡는다 —
|
|
825
831
|
`foreignKey`/`otherKey` 를 명시한다.
|
|
826
|
-
- **`updateAll`/`deleteAll` 에 limit·offset·orderBy 가 걸려 있으면 throw** —
|
|
832
|
+
- **`updateAll`/`deleteAll` 에 limit·offset·orderBy·distinct 가 걸려 있으면 throw** —
|
|
827
833
|
행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`.
|
|
828
834
|
- **벌크 3종은 행이 아니라 `{ count }` 를 준다** (결정 90) — 반환을 `Rec[]` 처럼
|
|
829
835
|
다루지 말 것. 삽입된 행이 필요하면 소량은 `create()`, 대량은 유니크 키 재조회.
|
|
@@ -885,4 +891,8 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
885
891
|
| 결정 119 | `paginate(page, perPage)` — 체인 종단 `{rows,total,page,pageCount,perPage}` · 클램프·개수 number 내장 · UI 킷 Pagination 정합 · 손 조립(쿼리 2회·count 캐스팅·페이지 수학)은 반정본 · GroupChain 미탑재(행 목록 전용) |
|
|
886
892
|
| 결정 148 | 캐시 헬퍼 `cache.remember`/`forget`·쿼리 `.withCache(ttl)` — 명시 TTL 만 · **자동 무효화 없음**(쓰기 자동 퍼지 기각 · 조용한 stale 방지) · Redis 기본·메모리 폴백(§8.2) |
|
|
887
893
|
| 결정 153 | `Model.form` 컬럼 제약(`.max`·enum) 쓰기 전 서버측 검증 → 422(폼 에러) · DB 제약 위반 raw 500 방지(§8.1 · `this.params`) |
|
|
894
|
+
| 결정 220 | 마이그레이션 diff 확장 — 기존 컬럼의 `.unique()`·`.index()`·`.default()`·`.check()` 추가/제거를 diff 로 잡음(§10) |
|
|
895
|
+
| 결정 221 | 크로스 커넥션 트랜잭션 런타임 가드 — tx 안 다른 커넥션 **쓰기**(INSERT/UPDATE/DELETE) throw · **읽기는 예외** · 중첩 서비스/afterCommit/tx:false 는 자기 경계로 통과(§5 · 규칙 9) |
|
|
896
|
+
| 결정 253 | hidden 마커를 **열거 가능한 심볼**로 부여 — `{ ...row }` spread·`Object.assign` 을 넘어 보존돼 우회 유출(S1) 봉합(§1) |
|
|
897
|
+
| 결정 265 | 조인 시 자기 테이블 컬럼 자동 한정 — `where`/`whereAny`/`orderBy` 가 자기 테이블로 한정돼 ambiguous column 방지(§4 join) |
|
|
888
898
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -55,6 +55,11 @@ const props = pageProps<'web:posts#index'>()
|
|
|
55
55
|
로 서버가 번역해 흘려보낸다(`agents/i18n.md` §5 · 결정 213).
|
|
56
56
|
`pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
|
|
57
57
|
`useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
|
|
58
|
+
- **구조분해 금지 — `pageProps` 와 같은 함정** (`packages/vue/src/shared.ts`). `useShared()`
|
|
59
|
+
는 매 접근마다 `usePage().props` 를 다시 읽는 **Proxy** 를 돌려준다. `const { csrf } =
|
|
60
|
+
useShared()` 처럼 구조분해하면 그 순간 값을 한 번 스냅샷해 **반응성이 끊긴다**(리다이렉트·
|
|
61
|
+
partial reload 후 flash·currentUser 갱신이 안 보인다). 항상 `const shared = useShared()`
|
|
62
|
+
로 받아 `shared.csrf`·`shared.flash` 로 접근한다.
|
|
58
63
|
- **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
|
|
59
64
|
로 import 하지 않는다.
|
|
60
65
|
- **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
|
|
@@ -70,6 +75,20 @@ const props = pageProps<'web:posts#index'>()
|
|
|
70
75
|
의 `Link`(`<Link href="/posts">글 목록</Link>`), 코드에서의 이동은
|
|
71
76
|
`router.visit(url)`. **외부 URL(`https://…`)·`target="_blank"` 만 `<a>`** 를
|
|
72
77
|
유지한다. 내부 경로 일반 앵커는 doctor **internal-anchor** 가 잡는다(결정 96).
|
|
78
|
+
- **페이지 제목 = `Head`(결정 271)** — 문서 `<title>` 은 `gaonjs/vue` 의 `Head` 로
|
|
79
|
+
설정한다. `createGaonApp({ title })` 조합자가 이 값을 받아 `글 목록 · 사이트명` 처럼
|
|
80
|
+
꾸민다. `document.title` 수동 조작·`@inertiajs` 직접 import 금지(결정 64) — 정본
|
|
81
|
+
표면은 `Head`(`gaonjs/vue` 재수출) 뿐이다. 페이지 `<template>` 최상단에 둔다:
|
|
82
|
+
```vue
|
|
83
|
+
<script setup lang="ts">
|
|
84
|
+
import { Head, pageProps } from 'gaonjs/vue'
|
|
85
|
+
const props = pageProps<'web:posts#index'>()
|
|
86
|
+
</script>
|
|
87
|
+
<template>
|
|
88
|
+
<Head title="글 목록" />
|
|
89
|
+
<!-- 이하 페이지 본문 -->
|
|
90
|
+
</template>
|
|
91
|
+
```
|
|
73
92
|
- **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
|
|
74
93
|
금지 (라우트를 모른다는 순수 규칙).
|
|
75
94
|
|
|
@@ -398,6 +417,10 @@ async function runSearch(q: string) {
|
|
|
398
417
|
|
|
399
418
|
- **`usePage()`·`defineProps<T>()` 로 pageProps 대체 금지** — Serialized
|
|
400
419
|
경계 우회로 타입 안전 붕괴.
|
|
420
|
+
- **`pageProps()`·`useShared()` 는 setup 컨텍스트 전용** — `<script setup>` 최상위
|
|
421
|
+
(또는 컴포저블)에서 호출해야 `usePage()` inject 가 성립한다(`pageProps.ts`). 이벤트
|
|
422
|
+
핸들러·`await` 뒤·setup 밖에서 부르면 inject 가 없어 깨진다. setup 에서 한 번 잡아
|
|
423
|
+
(반응형 프록시라 그대로) 쓴다.
|
|
401
424
|
- **`api()` 에 제네릭 인자 직접 붙이지 않는다** — key 리터럴이 타입을
|
|
402
425
|
결정한다.
|
|
403
426
|
- **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
|
|
@@ -448,6 +471,8 @@ async function runSearch(q: string) {
|
|
|
448
471
|
| 결정 25 (E-5) | 컴포저블·레이아웃 관례 · 프론트 로직 배치 3규칙 · 자동 import 금지 |
|
|
449
472
|
| 결정 37 | bigint PK 컨트롤러 `String()` 정규화 |
|
|
450
473
|
| 결정 46 | doctor page-filename(페이지 PascalCase)·model/column 검사 3종 |
|
|
474
|
+
| 결정 64 | 폼 API = `gaonjs/vue` `useForm`·`router`(@inertiajs 직접 import 금지) · HTML 폼 불가 메서드(DELETE 등)는 `router.delete()` · Inertia=SPA+서버 라우팅(`agents/web.md` §4) |
|
|
475
|
+
| 결정 99 | `pageProps()` 반환 = usePage().props 얇은 반응형 프록시(매 접근 최신) · 구조분해=스냅샷이라 변수로 받아 `props.x` 로 접근 |
|
|
451
476
|
| 결정 69 | 랜딩 정본(라이브 헬스 카드 · 다크 헤더 레이아웃 · 실 상태 · 코드 블록) |
|
|
452
477
|
| 결정 70 | auth 통합 = 수동(`gaon g auth` 는 랜딩·nav 를 안 건드림 · Rails 관례) |
|
|
453
478
|
| 결정 71 | HUB 카드 실 introspection(jobs=스트림 pending · channels=프레즌스 실 접속자 · 과대 약속 금지) |
|
|
@@ -464,9 +489,12 @@ async function runSearch(q: string) {
|
|
|
464
489
|
| 결정 166 | `api()` CSRF 자동 부착 = data-page `props.csrf`(결정 116 과 같은 단일 출처) · `<meta name="csrf-token">` 은 레거시 폴백(§2 · `packages/vue/src/api.ts`) |
|
|
465
490
|
| 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
|
|
466
491
|
| 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
|
|
492
|
+
| 결정 128 | `useChannel` 자동 재연결(지수 백오프 1s·2s·5s·10s·지터 · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401 은 재연결 안 함 · `agents/realtime.md` §4) |
|
|
467
493
|
| 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
|
|
468
494
|
| 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
|
|
469
495
|
| 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
|
|
496
|
+
| 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §7) |
|
|
497
|
+
| 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
|
|
470
498
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
471
499
|
|
|
472
500
|
## `@gaonjs/seal` 켠 앱의 프론트
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
### 1. 카탈로그와 `t()`
|
|
10
10
|
|
|
11
11
|
번역 문자열은 프로젝트 루트 `locales/<로케일>.json` 에 둔다(중첩 JSON = 점 표기
|
|
12
|
-
키). `t('key')`
|
|
13
|
-
|
|
12
|
+
키). `t('key')` 는 **현재 요청 로케일**의 문자열을 얻는다 — 요청 컨텍스트 안
|
|
13
|
+
(컨트롤러·서비스)에서 자동으로 따라간다. **요청 밖(잡·크론·스크립트)에는 요청
|
|
14
|
+
로케일이 없어 항상 fallback** 이므로, 로케일을 명시적으로 실어 `runWithLanguage`
|
|
15
|
+
로 감싼다(메일은 `deliver(data, { locale })` 로 대칭 · §정본 예시).
|
|
14
16
|
|
|
15
17
|
```json
|
|
16
18
|
// locales/ko.json
|
|
@@ -29,8 +31,9 @@ t('nav.home') // 중첩은 점 표기
|
|
|
29
31
|
|---|---|---|
|
|
30
32
|
| 번역 | `t(key, params?)` | 키는 카탈로그에서 타입 검사(아래 §4) · params 는 `{{name}}` 보간 |
|
|
31
33
|
| 현재 언어 | `currentLanguage(): string` | 요청 로케일 |
|
|
32
|
-
| 지원 언어 | `languages(): string[]` | 설정된 supportedLngs |
|
|
34
|
+
| 지원 언어 | `languages(): readonly string[]` | 설정된 supportedLngs(읽기 전용) |
|
|
33
35
|
| 고정 번역 | `runWithLanguage(lng, fn)` | fn 안의 t() 가 그 언어(메일·비요청 경로 · §mail) |
|
|
36
|
+
| 고정 번역기 | `translator(lng)` | 언어를 고정한 번역 함수 `(key, params?) => string` 를 돌려준다(테스트·비요청 경로) |
|
|
34
37
|
|
|
35
38
|
### 1.5 복수형 — `count` 로 자동 선택 (i18next 규약 · 결정 181)
|
|
36
39
|
|
|
@@ -81,9 +84,11 @@ export default defineConfig({
|
|
|
81
84
|
없는 앱(랜딩·API)에서도·앱 간에도 유지된다.
|
|
82
85
|
|
|
83
86
|
```ts
|
|
84
|
-
// 컨트롤러 — 언어 전환 라우트
|
|
85
|
-
async
|
|
86
|
-
this.
|
|
87
|
+
// 컨트롤러 — 언어 전환 라우트 (액션명은 컨텍스트 메서드 this.setLocale 과 겹치지 않게)
|
|
88
|
+
async switchLocale() {
|
|
89
|
+
// 입력은 정본 this.params — raw this.request.params + cast 대신 통합 입력 접근자.
|
|
90
|
+
const { lng } = this.params({ _row: {} as { lng: string } })
|
|
91
|
+
this.setLocale(lng)
|
|
87
92
|
return this.redirect(this.request.headers.referer ?? '/')
|
|
88
93
|
}
|
|
89
94
|
```
|
|
@@ -138,6 +143,7 @@ nav 라벨·레이아웃 문구처럼 앱의 **모든** 페이지가 쓰는 chro
|
|
|
138
143
|
|
|
139
144
|
```ts
|
|
140
145
|
// apps/web/app.config.ts
|
|
146
|
+
import { defineAppConfig } from 'gaonjs/config'
|
|
141
147
|
import { t } from 'gaonjs/i18n'
|
|
142
148
|
export default defineAppConfig({
|
|
143
149
|
sharedProps: () => ({
|
|
@@ -205,16 +211,28 @@ CSS 가 올바른 언어를 안다). 비-i18n 프로젝트는 템플릿 정적
|
|
|
205
211
|
## 정본 예시
|
|
206
212
|
|
|
207
213
|
```ts
|
|
208
|
-
// domain/services/greet.ts —
|
|
214
|
+
// domain/services/greet.ts — 서비스는 요청 컨텍스트 안이라 t() 가 요청 로케일을 쓴다.
|
|
209
215
|
import { t } from 'gaonjs/i18n'
|
|
210
216
|
export function greetLine(name: string): string {
|
|
211
217
|
return t('greeting', { name })
|
|
212
218
|
}
|
|
213
219
|
```
|
|
214
220
|
|
|
221
|
+
```ts
|
|
222
|
+
// domain/jobs/sendDigest.ts — 잡은 `gaon work`(별도 프로세스)라 요청 컨텍스트가 없다.
|
|
223
|
+
// 로케일을 페이로드에 실어 runWithLanguage 로 감싼다(안 그러면 t() 는 fallback).
|
|
224
|
+
import { job } from 'gaonjs/async'
|
|
225
|
+
import { runWithLanguage, t } from 'gaonjs/i18n'
|
|
226
|
+
|
|
227
|
+
export const SendDigest = job(async ({ userId, locale }: { userId: bigint; locale: string }) => {
|
|
228
|
+
const line = runWithLanguage(locale, () => t('greeting', { name: '가온' }))
|
|
229
|
+
// …line 으로 메일/알림 조립
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
215
233
|
`t()` 는 요청 컨텍스트(ALS)의 로케일을 자동으로 따라간다 — 로케일을 인자로
|
|
216
|
-
넘기고 다니지 않는다.
|
|
217
|
-
`runWithLanguage(lng, () => t('key'))
|
|
234
|
+
넘기고 다니지 않는다. **요청 밖(잡·크론·스크립트)** 이나 특정 로케일로 강제하려면
|
|
235
|
+
`runWithLanguage(lng, () => t('key'))` — 잡은 로케일을 페이로드에 담아 넘긴다(위 예).
|
|
218
236
|
|
|
219
237
|
## 알려진 함정
|
|
220
238
|
|
|
@@ -222,8 +240,10 @@ export function greetLine(name: string): string {
|
|
|
222
240
|
`gaon.config.ts` 에 `i18n` 이 있어야 배선된다(결정 159). 설정만 하면 자동.
|
|
223
241
|
- **키를 손으로 `string` 으로 넓히지 말 것** — `.gaon/messages.d.ts`(결정 158)가
|
|
224
242
|
키를 타입으로 좁혀 준다. `gaon check` 가 없는 키를 잡는다.
|
|
225
|
-
-
|
|
226
|
-
전환은 `this.setLocale`, 특정 로케일 강제는 `runWithLanguage`.
|
|
243
|
+
- **요청 코드에선 로케일을 함수 인자로 실어 나르지 말 것** — `t()` 는 ALS 로 요청
|
|
244
|
+
로케일을 안다. 전환은 `this.setLocale`, 특정 로케일 강제는 `runWithLanguage`. **예외:
|
|
245
|
+
잡·크론(요청 밖)은 요청 로케일이 없으므로** 로케일을 페이로드에 담아 `runWithLanguage`
|
|
246
|
+
로 감싼다(§정본 예시 · 메일 `deliver(data,{locale})` 와 동형).
|
|
227
247
|
- **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })` 로
|
|
228
248
|
명시한다(`agents/mail.md` · 결정 160).
|
|
229
249
|
- **검증 실패 문안도 로케일화된다** — 예약 namespace `validation.<code>`(예 `validation.required`)
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
메일은 `domain/mails/<이름>.ts` 에 `mail()` 로 정의한다(모델·잡과 같은 함수/객체
|
|
12
12
|
스타일 · 데코레이터 금지). 파일을 두면 등록이고 파일명이 곧 이름이다. build 함수는
|
|
13
|
-
데이터를 받아 메시지(`to`·`subject`·`html`/`text`·`from?`)를
|
|
13
|
+
데이터를 받아 메시지(`to`·`subject`·`html`/`text`·`from?`·`cc?`·`bcc?`·`replyTo?`)를
|
|
14
|
+
만든다. `to`·`cc`·`bcc` 는 `string | readonly string[]`(여러 수신자), `replyTo` 는 단일
|
|
15
|
+
`string` 이다. build 함수는 동기·비동기(`async`) 둘 다 된다(`MailMessage | Promise<MailMessage>`).
|
|
14
16
|
|
|
15
17
|
```ts
|
|
16
18
|
// domain/mails/welcome.ts
|
|
@@ -26,9 +28,9 @@ export const WelcomeMail = mail<{ name: string; email: string }>((u) => ({
|
|
|
26
28
|
|
|
27
29
|
| 표면 | 시그니처 | 비고 |
|
|
28
30
|
|---|---|---|
|
|
29
|
-
| 정의 | `mail<T>((data) => MailMessage)` | 파일명 = 이름 |
|
|
30
|
-
| 발송 | `def.deliver(data, { locale?, to? })
|
|
31
|
-
| 미리보기 | `def.render(data, { locale?, to? })
|
|
31
|
+
| 정의 | `mail<T>((data) => MailMessage \| Promise<MailMessage>)` | 파일명 = 이름 · build 는 async 가능 |
|
|
32
|
+
| 발송 | `def.deliver(data, { locale?, to? }): Promise<SentInfo>` | 설정된 SMTP 로 보냄(`await`) |
|
|
33
|
+
| 미리보기 | `def.render(data, { locale?, to? }): Promise<MailMessage>` | 발송 없이 메시지만(테스트·미리보기 · `await`) · `to` 는 `deliver` 와 대칭 |
|
|
32
34
|
|
|
33
35
|
### 2. 로케일 메일 — `deliver(data, { locale })` (결정 160)
|
|
34
36
|
|
|
@@ -60,7 +60,7 @@ export default channel({
|
|
|
60
60
|
|
|
61
61
|
| 훅 | 시점 | 반환 |
|
|
62
62
|
| --- | --- | --- |
|
|
63
|
-
| `authorize(ctx)` | 소켓 attach 전 | `boolean
|
|
63
|
+
| `authorize(ctx)` | 소켓 attach 전 | `boolean \| Promise<boolean>` — false 면 연결 거부(4401 close) · async 가능 |
|
|
64
64
|
| `presenceInfo(ctx)` | attach 전 | 접속자 목록에 실을 공개 메타 |
|
|
65
65
|
| `onJoin(ctx)` | 연결·프레즌스 등록 후 | — |
|
|
66
66
|
| `onMessage(ctx, data)` | 클라이언트 메시지 | — |
|
|
@@ -80,11 +80,15 @@ export default channel({
|
|
|
80
80
|
|
|
81
81
|
멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
|
|
82
82
|
|
|
83
|
-
**핸들러가 throw
|
|
84
|
-
예외(코드 결함·DB 순단)가 나도
|
|
85
|
-
마감되는 것과
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
**핸들러가 throw 해도 서버는 죽지 않는다(결정 257).** `onMessage`/`onLeave` 안에서
|
|
84
|
+
예외(코드 결함·DB 순단)가 나도 그 예외는 **연결 단위로 격리**된다 — HTTP 액션이 500
|
|
85
|
+
으로 마감되는 것과 대칭이다(다른 연결·서버는 그대로 산다). 단 **두 훅의 마감이 다르다**:
|
|
86
|
+
- `onMessage` throw → 그 연결에 **에러 프레임(`{ t:'error' }`)을 보내고 `1011` 로 종료**한다
|
|
87
|
+
(요청 실패지만 서버는 생존 = HTTP 500 대칭).
|
|
88
|
+
- `onLeave` throw → 연결이 **이미 닫히는 중**이라 통지할 곳이 없다. **로그만 남기고 정리(프레즌스
|
|
89
|
+
해제 등)를 계속**한다(추가 종료·에러 프레임 없음).
|
|
90
|
+
|
|
91
|
+
재시도가 필요한 로직은 핸들러 안에서 try/catch 로 감싸 직접 통제한다.
|
|
88
92
|
|
|
89
93
|
### 2.5 서버 개시 broadcast (결정 126)
|
|
90
94
|
|
|
@@ -190,21 +194,25 @@ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{
|
|
|
190
194
|
import { useChannel } from 'gaonjs/vue'
|
|
191
195
|
|
|
192
196
|
export function useRoom(roomId: number) {
|
|
193
|
-
// messages(반응형)·status·send·connect·close 를 돌려준다. 마운트에 접속.
|
|
194
|
-
const { messages, status, send } = useChannel('room', {
|
|
197
|
+
// messages·members(반응형)·status·send·connect·close 를 돌려준다. 마운트에 접속.
|
|
198
|
+
const { messages, members, status, send } = useChannel('room', {
|
|
195
199
|
params: { room: roomId },
|
|
196
200
|
onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
|
|
197
|
-
|
|
201
|
+
// members = 현재 접속자 **전체 명단**. 스냅샷·들어옴·나감을 하나로 반영하므로
|
|
202
|
+
// 델타를 손으로 병합하지 않는다. 반응형이라 template 에서 그대로 렌더해도 된다.
|
|
203
|
+
onPresence: (members) => { /* 접속자 명단이 바뀔 때마다 전체 명단으로 호출 */ },
|
|
198
204
|
onReconnect: () => { /* 재연결됨 — 놓친 데이터를 Inertia partial reload 로 따라잡기 */ },
|
|
199
205
|
})
|
|
200
|
-
return { messages, status, send }
|
|
206
|
+
return { messages, members, status, send }
|
|
201
207
|
}
|
|
202
208
|
```
|
|
203
209
|
|
|
204
210
|
- **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
|
|
205
211
|
정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
|
|
206
|
-
- **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage`
|
|
207
|
-
|
|
212
|
+
- **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출. 접속자 프레임(초기
|
|
213
|
+
스냅샷 + 이후 들어옴/나감)은 하나의 **명단**으로 합쳐져 반응형 `members` 에 반영되고
|
|
214
|
+
`onPresence(members)` 로도 통지된다(결정 272 · 콜백만으로 항상 최신 명단 · 델타 병합
|
|
215
|
+
불요). 그 외 종류는 `onFrame`(저수준 탈출구).
|
|
208
216
|
- **자동 재연결(결정 128 · 기본 켬)** — 소켓이 끊기면(서버 재시작·네트워크 blip)
|
|
209
217
|
useChannel 이 **지수 백오프**(1s·2s·5s·10s · 이후 10s 반복 · 지터)로 자동 재접속한다.
|
|
210
218
|
새 소켓은 서버가 다시 인가하고 프레즌스 스냅샷을 다시 밀어주므로 접속자 목록이
|
|
@@ -311,7 +319,9 @@ export default channel({
|
|
|
311
319
|
연결에만 전달 · 결정 227). `sendToUsers` 는 그 **채널에 접속한** 유저만
|
|
312
320
|
대상이다 — 접속 안 한(오프라인) 유저는 `0` 도달로 반환된다.
|
|
313
321
|
- **자기 자신의 join 델타** — 클라이언트는 자기 `presence:join` 델타도
|
|
314
|
-
스냅샷과 별개로
|
|
322
|
+
스냅샷과 별개로 받는다. `useChannel` 은 `members` 를 `id` 로 키잉해 이 중복을
|
|
323
|
+
**멱등**으로 흡수하므로 명단이 불어나지 않는다(결정 272). 직접 `new WebSocket`
|
|
324
|
+
을 쓰는 탈출구에서는 스스로 `id` 로 dedup 한다.
|
|
315
325
|
- **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
|
|
316
326
|
(`agents/testing.md`).
|
|
317
327
|
|
|
@@ -326,9 +336,10 @@ export default channel({
|
|
|
326
336
|
| 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
|
|
327
337
|
| 결정 225 | 프레즌스 연결 축 refcount(§3) — 같은 멤버의 멀티탭·멀티서버 연결을 refcount 해 마지막 연결에서만 이탈 · cleanupServer 는 그 서버 연결만 회수(타서버 불간섭) |
|
|
328
338
|
| 결정 227 | 특정/다중 유저 타겟 발송(§2.6) — `sendToUsers(name, userIds, data)` · `broadcast` 와 대칭 · 대상 연결에만 전달(멀티서버·멀티탭) · 도달 유저 수 반환(오프라인=0) · 수정 1 연결 추적 위에 얹음 |
|
|
329
|
-
| 결정 257 | 채널 `onMessage`/`onLeave` throw 는
|
|
339
|
+
| 결정 257 | 채널 `onMessage`/`onLeave` throw 는 연결 단위로 격리(§2) — 사용자 핸들러 예외가 unhandledRejection 으로 serve 를 죽이지 않는다(HTTP 500 대칭) · `onMessage` = 에러 프레임 + `1011` 종료 · `onLeave` = 이미 닫히는 중이라 로그만·정리 계속 |
|
|
330
340
|
| 결정 259 | 허브 디스커버리 endpoint 는 소유 리더만 삭제(§5) — addr 일치 + revision CAS · standby 종료·리더 교대가 활성 endpoint 를 지우지 않음(재접속 서버 허브 발견 보존) |
|
|
331
341
|
| 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
|
|
342
|
+
| 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
|
|
332
343
|
|
|
333
344
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
334
345
|
|
|
@@ -59,7 +59,11 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
|
|
|
59
59
|
| CSRF · 무차별 요청(DoS) | ❌ | 세션 CSRF · rate limit |
|
|
60
60
|
| 작정한 공격자의 봉인 위조 | ❌(클라에 규약 있음) | 위 서버 검증 전부 |
|
|
61
61
|
|
|
62
|
-
##
|
|
62
|
+
## 정본 규칙
|
|
63
|
+
|
|
64
|
+
(§0 은 포지셔닝 프리앰블 — 켜기 전에 반드시 읽는다. 아래 §1~§5 가 실제 정본 규칙이다.)
|
|
65
|
+
|
|
66
|
+
### 1. 켜는 법 — The One Way (결정 121·124)
|
|
63
67
|
|
|
64
68
|
```bash
|
|
65
69
|
1) npm i @gaonjs/seal # 선택 플러그인 · 기본 스캐폴드 미포함
|
|
@@ -82,13 +86,13 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
|
82
86
|
vite** 가 번들하도록 `main.ts` 에서 **app-side 정적 import** 로 주입한다. (과거 `@gaonjs/vue` 안의
|
|
83
87
|
**변수-specifier 동적 import** 는 vite 가 번들하지 못해 실 브라우저 마운트가 blank 로 깨졌다 — **폐기**.)
|
|
84
88
|
- **doctor 가 지킨다**: `seal-security` 가 `seal: true` 인데 main.ts 배선이 없으면 **에러 + 수리 안내**를 내고,
|
|
85
|
-
`gaon
|
|
89
|
+
`gaon doctor --fix --yes` 의 `seal-client-wiring` fixer 가 배선을 자동 생성한다. 개발자·AI 멘탈모델은 여전히
|
|
86
90
|
"seal: true 한 줄" 이고, 배선 누락은 doctor 가 즉시 잡는다(§7.5.3 에러=수리 안내서). 런타임도 봉인 문서를
|
|
87
91
|
배선 없이 받으면 정확한 수리 메시지로 throw 한다(blank 대신).
|
|
88
92
|
- 미설치로 `seal: true` 를 켜면 **부팅 에러**(수리 안내). `masterSecret` 설정 표면은 없다 — 미끼 literal
|
|
89
93
|
이라 설정할 이유가 없다(비밀 착시·랜덤화 사고 방지).
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
### 2. 무엇이 봉인되나
|
|
92
96
|
|
|
93
97
|
- **요청/응답 JSON**: 클라 `installClientSeal()` 이 Inertia XHR 인터셉터(`XMLHttpRequest.prototype`) +
|
|
94
98
|
`api()`/`fetch` 봉인을 설치한다. 서버는 Fastify **4-stage 훅**(`plugin.ts` · onRequest 분류/fail-closed →
|
|
@@ -112,17 +116,18 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
|
112
116
|
- **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON 도 Inertia 도 아닌 HTML
|
|
113
117
|
직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
|
|
114
118
|
모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
|
|
115
|
-
- **fail-closed (403 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
116
|
-
**403 SealError** — 평문 통과 절대 없음.
|
|
119
|
+
- **fail-closed (403·413 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
120
|
+
**403 SealError** — 평문 통과 절대 없음. **과대 요청 본문(상한 초과 · `PAYLOAD_TOO_LARGE`)만 예외로 413**
|
|
121
|
+
(`readStream` OOM 방어 · `errors.ts`). WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
|
|
117
122
|
fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단).
|
|
118
123
|
|
|
119
|
-
|
|
124
|
+
### 3. CSP (결정 124)
|
|
120
125
|
|
|
121
126
|
seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입**한다(wasm 컴파일만 허용 ·
|
|
122
127
|
`'unsafe-eval'` 보다 좁음). **전역 완화 금지** — 비-seal 앱은 strict CSP(`script-src 'self'`) 그대로다.
|
|
123
128
|
요청 단위 판별(seal 스코프가 요청을 표시 · 보안 헤더 훅이 그 응답에만 보정).
|
|
124
129
|
|
|
125
|
-
|
|
130
|
+
### 4. 아키텍처 경계 (AI 가 넘지 말 것)
|
|
126
131
|
|
|
127
132
|
- **`@gaonjs/vue` 는 seal 무지 유지 · 비-seal 앱 번들에 wasm 0.** 두 번째 http/ws 클라이언트를 이식하지
|
|
128
133
|
않는다 — 봉인/개봉은 기존 전송 경로(Inertia·`api()`·`useChannel`) **경계 인터셉터**가 한다.
|
|
@@ -152,7 +157,7 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
152
157
|
- **허브(`gaon hub`)는 손대지 않는다** — 봉인/개봉은 각 웹서버의 소켓 경계에서만. 타 서버 접속자의
|
|
153
158
|
UA·ts 컨텍스트가 없어 허브가 프레임을 복호할 수 없는 것은 구조적 필연(설계상) · 허브·NATS 내부는 평문.
|
|
154
159
|
|
|
155
|
-
|
|
160
|
+
### 5. 게이트 — seal 검증은 **실 브라우저가 blocking** (결정 124)
|
|
156
161
|
|
|
157
162
|
- 정본 게이트는 **실 vite 프로덕션 빌드 + 실 chromium + 실 wasm** e2e 다:
|
|
158
163
|
`test/integration/seal-browser-e2e.integration.test.ts`(마운트·data-page 개봉·useForm POST·api()·WS `E:` 왕복·
|
|
@@ -168,9 +173,32 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
168
173
|
(Playwright `page.waitForResponse(...).text()`·`page.on('response')` · 외부 `curl`)로 **시그널 헤더 + 암호문**을
|
|
169
174
|
직접 봐야 드러난다. 정본 게이트 ⑨(결정 125)가 이 방식으로 네비게이션 wire 봉인을 정면 단언한다.
|
|
170
175
|
|
|
176
|
+
## 정본 예시
|
|
177
|
+
|
|
178
|
+
봉인은 **켜는 것**이 전부다 — 컨트롤러·`this.params`·`api()`·페이지 코드는 한 줄도 안 바뀐다(§2 wire 봉인은 훅/인터셉터가 담당).
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// apps/web/app.config.ts — 앱 wire 전체 봉인.
|
|
182
|
+
export default defineAppConfig({ seal: true })
|
|
183
|
+
```
|
|
184
|
+
```ts
|
|
185
|
+
// apps/web/main.ts — seal 클라이언트 정적 import 를 createGaonApp 에 주입(결정 124 · doctor 가 강제·fixer 자동 배선).
|
|
186
|
+
import { createGaonApp } from 'gaonjs/vue'
|
|
187
|
+
import * as sealClient from '@gaonjs/seal/client'
|
|
188
|
+
void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
189
|
+
```
|
|
190
|
+
```ts
|
|
191
|
+
// 컨트롤러는 그대로 — seal 을 전혀 모른다(요청/응답 JSON·최초 문서 data-page·WS 프레임이 자동 봉인).
|
|
192
|
+
export default controller({
|
|
193
|
+
async index() {
|
|
194
|
+
return this.render('Posts/Index', { posts: await Post.latest().all() })
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
171
199
|
## 알려진 함정
|
|
172
200
|
|
|
173
|
-
1. **main.ts 배선 누락** → 봉인 문서를 브라우저가 못 열어 화면 blank. → `gaon check`(`seal-security`)가 잡고
|
|
201
|
+
1. **main.ts 배선 누락** → 봉인 문서를 브라우저가 못 열어 화면 blank. → `gaon check`(`seal-security`)가 잡고 `gaon doctor --fix --yes` 가 배선. 런타임도 수리 안내 throw.
|
|
174
202
|
2. **변수-specifier 동적 import** (`const s='@gaonjs/seal/client'; import(s)`) — vite 가 번들 못 해 프로덕션 blank. **폐기됨** — app-side 정적 주입만.
|
|
175
203
|
3. **전역 CSP 완화 금지** — `'wasm-unsafe-eval'` 은 seal 앱 응답에만. 비-seal 앱 strict CSP 유지.
|
|
176
204
|
4. **"seal 켰으니 검증 느슨" = 가짜 안심** — 서버 방어층(§0) 전부 유지. seal 은 이유가 못 된다.
|
|
@@ -60,6 +60,13 @@
|
|
|
60
60
|
- **로그인은 세션 ID 를 재생성하고 로그아웃은 세션을 파기한다 (결정 254)**:
|
|
61
61
|
`this.auth.login`/`logout` 이 자동 처리한다(session fixation 방어). 스캐폴드는
|
|
62
62
|
`await this.auth.login(user)` 형태다 — 손으로 세션을 조작하지 않는다.
|
|
63
|
+
- **가입 이메일은 유니크다 (결정 256)**: `gaon g auth` 스키마의 `email` 은
|
|
64
|
+
`t.string().max(255).unique()` — DDL UNIQUE 로 물성화돼 중복 가입을 DB 층에서
|
|
65
|
+
막는다(무결성 backstop). 가입 컨트롤러는 그 위에 **pre-check**(`if (await
|
|
66
|
+
User.where('email','=',email).first()) …`)를 깔아 중복 시 raw DB 500 대신
|
|
67
|
+
친절한 폼 에러(`props.error` → Alert)로 마감한다(유니크 제약이 TOCTOU 레이스의
|
|
68
|
+
backstop). 유니크 없이 두면 중복 가입이 조용히 성공하고 로그인 `.first()` 가
|
|
69
|
+
비결정이 된다.
|
|
63
70
|
- **비-web 앱은 시큐어 기본이다** (결정 155): `gaon g auth --app admin` 은
|
|
64
71
|
**공개 회원가입(registration)을 깔지 않는다** — 관리 앱에 공개 가입이 열리고
|
|
65
72
|
로그인한 일반 고객이 관리 화면을 보던 위험 기본을 구조적으로 막는다. 대신 보호
|
|
@@ -87,6 +94,11 @@
|
|
|
87
94
|
한다 — 폼(POST)을 추가하는 순간 CSRF 가 이미 켜져 있다. 앱에 비-GET
|
|
88
95
|
라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
|
|
89
96
|
`csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
|
|
97
|
+
- **CSRF 를 끄는 유일한 스위치는 `session: { csrf: false }` (결정 271 · 규칙 8).**
|
|
98
|
+
`app.config.ts` 의 `session` 에 `csrf: false` 를 명시할 때만 그 앱의 CSRF 검증이
|
|
99
|
+
꺼진다 — 생략·`undefined` 는 켬(보안 기본값). 커스텀 헤더 인증 등 세션 앱이면서
|
|
100
|
+
CSRF 를 꺼야 하는 특수 경로용 탈출구이고, 끄면 상태 변경 요청이 무방비가 되므로
|
|
101
|
+
이유를 남긴다. wire 가 이 값을 `SessionOptions.csrf` 로 그대로 전달한다.
|
|
90
102
|
- **CSRF/세션 실패는 코어가 Inertia-네이티브로 마감한다 (결정 165).** 세션 만료·
|
|
91
103
|
secret 로테이션·장시간 탭으로 CSRF 가 실패하면, Inertia 요청은 raw JSON 403 이 아니라
|
|
92
104
|
**409 + `X-Inertia-Location` 풀 리로드**(새 세션 쿠키+새 토큰) + `flash.error` 안내로
|
|
@@ -234,3 +246,6 @@ const rows = await Post.query()
|
|
|
234
246
|
| 결정 145 | 인가 프리미티브 `this.authorize(cond)` — 거짓 → 403(존재 은닉 시 404) · 인증(401)과 별개 축 · 저수준 탈출구 |
|
|
235
247
|
| 결정 149 | 인가 정책 객체 `policy()` + `this.can` — 재사용 규칙을 리소스별 액션→조건으로 묶음 · 값 객체(레지스트리 아님) · 가드는 `authorize(can(...))` 로 수렴 · authorize(cond) 무회귀(§2) |
|
|
236
248
|
| 결정 155 | `gaon g auth --app <비-web>` 시큐어 기본 — 공개 회원가입 미생성 + 역할 게이트(authorize) 예시 · web=공개가입 · `--public` opt-in(§2) |
|
|
249
|
+
| 결정 165 | 세션/CSRF 실패·415 를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 무 · 비-Inertia 는 JSON 유지(§2.3) |
|
|
250
|
+
| 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§2.3 · `this.auth`) |
|
|
251
|
+
| 결정 271 | CSRF 를 끄는 유일 스위치 = `session: { csrf: false }` — 생략은 켬(보안 기본값) · wire 가 `SessionOptions.csrf` 로 전달(§2.3) |
|
|
@@ -19,13 +19,16 @@
|
|
|
19
19
|
| 조회 | `Storage.get(key): Promise<Buffer \| null>` | 없으면 null |
|
|
20
20
|
| 삭제 | `Storage.delete(key)` | 멱등 |
|
|
21
21
|
| 존재 | `Storage.exists(key): Promise<boolean>` | |
|
|
22
|
-
| URL | `Storage.url(key, { expiresIn? }): Promise<string>` |
|
|
22
|
+
| URL | `Storage.url(key, { expiresIn? }): Promise<string>` | 드라이버별로 다름(아래) — 로컬=공개 경로 · s3=공개 URL 또는 presigned |
|
|
23
23
|
| 디스크 선택 | `Storage.disk('s3').put(...)` | 기본 디스크 외 다른 디스크로 |
|
|
24
24
|
|
|
25
|
-
- **URL 은 `Storage.url()` 한
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
- **URL 은 `Storage.url()` 한 곳**이지만 **드라이버로 갈린다**:
|
|
26
|
+
- **로컬 디스크**: 항상 `${baseUrl}/${key}` **공개 경로**를 만든다(서명 없음 · `expiresIn` 무시).
|
|
27
|
+
`baseUrl` 기본값은 `/storage`(웹이 그 경로를 정적 서빙·라우트로 노출) — presigned 개념이 없다.
|
|
28
|
+
- **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public)이 있으면 `${publicUrl}/${key}`,
|
|
29
|
+
없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
|
|
30
|
+
존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
|
|
31
|
+
표면은 `Storage.url()` 뿐이다.
|
|
29
32
|
- **키는 경로**다(`avatars/${user.id}.png`). 앞 슬래시는 정규화된다.
|
|
30
33
|
- **`contentType` 은 어댑터별로 다르게 반영된다**(결정 236): `s3` 는 객체
|
|
31
34
|
메타데이터로 저장해 다운로드·presign 시 그대로 나가고, **로컬**은 저장하지
|
|
@@ -55,7 +58,8 @@ storage: process.env.STORAGE_ENDPOINT
|
|
|
55
58
|
|
|
56
59
|
- dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
|
|
57
60
|
(결정 132 · zero-config). `cp .env.example .env && gaon dev` → 우회 0.
|
|
58
|
-
- 로컬 디스크: `{ driver: 'local', root: 'storage',
|
|
61
|
+
- 로컬 디스크: `{ driver: 'local', root: 'storage', baseUrl?: '/storage' }` — `url(key)`
|
|
62
|
+
= `baseUrl + '/' + key`(공개 경로 · `baseUrl` 생략 시 `/storage`).
|
|
59
63
|
- 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
|
|
60
64
|
만 env 로 바꾼다.
|
|
61
65
|
|
|
@@ -99,6 +103,19 @@ export default controller({
|
|
|
99
103
|
`<bucket>-test`, 로컬 root 는 `<root>-test` 로 격리된다(`<db>_test` 대칭 ·
|
|
100
104
|
`agents/testing.md`). 스토리지 잡 테스트는 스캐폴드 `test/setup.ts` 그대로 통과한다.
|
|
101
105
|
|
|
106
|
+
## 정본 예시
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// 저장 → 공개/서명 URL 얻기(드라이버 무관 · 같은 코드). url() 은 async.
|
|
110
|
+
const key = `avatars/${user.id}.png`
|
|
111
|
+
await Storage.put(key, buffer, { contentType: 'image/png' })
|
|
112
|
+
const src = await Storage.url(key) // 로컬=`/storage/avatars/<id>.png` · s3=공개 URL 또는 presigned
|
|
113
|
+
// 만료 있는 서명 URL(s3 · 로컬은 expiresIn 무시):
|
|
114
|
+
const tempLink = await Storage.url(key, { expiresIn: 600 })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
업로드(멀티파트) 수신·저장의 정본은 §3(`this.file('avatar')` → `Storage.put`).
|
|
118
|
+
|
|
102
119
|
## 알려진 함정
|
|
103
120
|
|
|
104
121
|
- **존재하지 않는 API 를 상상하지 말 것** — 파일 URL 은 `Storage.url()`,
|
|
@@ -25,12 +25,16 @@
|
|
|
25
25
|
- `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
|
|
26
26
|
테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
|
|
27
27
|
`GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 같은 접두가 **NATS KV 버킷**
|
|
28
|
-
(
|
|
29
|
-
|
|
28
|
+
(프레즌스·허브·역할별 리스)에도 적용돼(결정 203) 격리된다 — 리스 버킷은 결정 260 으로
|
|
29
|
+
역할마다 분리돼 `gaon_lease_<역할>`(예 `gaon_lease_hub_leader`) 이고, 테스트에선
|
|
30
|
+
`test_gaon_lease_hub_leader` 처럼 접두가 붙는다. 스트림뿐 아니라 KV 도 개발·운영 스택과 안 겹친다. 그래서
|
|
30
31
|
개발용 `gaon work` 가 떠 있어도 테스트 잡을 훔치지 않고 스케줄러 리더 경합도
|
|
31
32
|
안 생기며, 테스트가 남긴 잡을 개발 워커가 처리하지도 않는다 — **테스트 전에
|
|
32
33
|
워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이 격리가 없어 개발 스택과
|
|
33
34
|
섞이니 `gaon test` 를 쓴다.)
|
|
35
|
+
- **스캐폴드 `package.json` 의 `"test"` 스크립트가 곧 `gaon test`(결정 271)** — 그래서
|
|
36
|
+
`pnpm test`(또는 `npm test`)도 정본 하네스를 그대로 탄다. `"vitest run"` 으로 바꾸면
|
|
37
|
+
프로비저닝·스트림 격리가 사라지니 두지 말 것.
|
|
34
38
|
- SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
|
|
35
39
|
아니다.
|
|
36
40
|
|
|
@@ -39,6 +43,14 @@
|
|
|
39
43
|
- 통합 테스트는 `test/integration/<이름>.integration.test.ts` — 러너는
|
|
40
44
|
vitest (`gaon test --scope integration`).
|
|
41
45
|
- 단위 테스트는 소스 옆 `<이름>.test.ts` (`--scope unit`).
|
|
46
|
+
- **필터·인자 전달** — `gaon test <필터>`(예 `gaon test posts`)는 `posts` 를 vitest
|
|
47
|
+
파일명 필터로 그대로 넘긴다. `gaon test -- <vitest 인자>`(예 `gaon test -- --reporter=dot`)
|
|
48
|
+
로 vitest 플래그를 통과시킨다 — `--scope`/`--json` 만 gaon 이 소비하고 나머지는 전부
|
|
49
|
+
vitest 에 그대로 전달된다.
|
|
50
|
+
- **MCP `run_tests` 도 같은 하네스를 탄다(결정 270)** — 내장 MCP 서버의 `run_tests` 도구는
|
|
51
|
+
vitest 를 직접 spawn 하지 않고 `gaon test`(`runTestCommand`)에 위임한다. 그래서 AI 가
|
|
52
|
+
MCP 로 돌려도 테스트 DB 프로비저닝·`GAON_STREAM_PREFIX` 격리·사용자 `test` 스크립트
|
|
53
|
+
우선이 그대로 적용된다(필터·`--scope` 인자도 동일).
|
|
42
54
|
- `vi.mock('gaonjs/async')` · `vi.mock('nats')` · `vi.mock('@nats-io/…')`
|
|
43
55
|
같은 프레임웍·전송 목업은 금지다 (§9) — 실 접속으로 검증한다.
|
|
44
56
|
|
|
@@ -168,9 +168,11 @@ export default controller({
|
|
|
168
168
|
`this.file()` 업로드(멀티파트)의 CSRF 토큰은 **`x-csrf-token` 헤더**로 보낸다.
|
|
169
169
|
멀티파트는 `parts()` 스트리밍이라 CSRF 검사(preHandler) 시점에 **바디가 아직
|
|
170
170
|
파싱되지 않아** 폼 필드 `_csrf` 가 검사에 잡히지 않는다(구조적 한계 · 디스패처가
|
|
171
|
-
handler 안에서 파싱). 일반
|
|
171
|
+
handler 안에서 파싱). 일반 JSON 폼의 `_csrf` 바디 폴백은 멀티파트엔
|
|
172
172
|
통하지 않는다. 파일이 있으면 `useForm` 이 자동으로 multipart 로 보내므로, 업로드
|
|
173
|
-
제출은 **반드시 헤더**로 토큰을 실어야 한다.
|
|
173
|
+
제출은 **반드시 헤더**로 토큰을 실어야 한다. (참고: 지원 Content-Type 은
|
|
174
|
+
`application/json` · `multipart/form-data` 뿐이라 `x-www-form-urlencoded` 로 폼을
|
|
175
|
+
보내면 `_csrf` 폴백에 닿기 전에 415 로 거부된다 · `inertia.ts` · §아래 415.)
|
|
174
176
|
|
|
175
177
|
```vue
|
|
176
178
|
<script setup lang="ts">
|
|
@@ -257,9 +259,12 @@ async create() {
|
|
|
257
259
|
| `required` | — | 필수 컬럼 누락 |
|
|
258
260
|
| `too_long` | `{ max, len }` | `.max(n)` 초과 |
|
|
259
261
|
| `not_allowed` | `{ value, allowed }` | enum 밖 값 |
|
|
260
|
-
| `
|
|
262
|
+
| `not_number` | `{ value }` | number·numeric(decimal) 변환 실패 |
|
|
263
|
+
| `not_integer` | `{ value }` | 정수(integer·smallint·bigint) 변환 실패 |
|
|
261
264
|
| `not_boolean` | `{ value }` | boolean 변환 실패 |
|
|
262
265
|
| `not_date` | `{ value }` | 날짜 변환 실패 |
|
|
266
|
+
| `not_uuid` | `{ value }` | uuid 형식 아님 |
|
|
267
|
+
| `invalid` | — | 그 외 coerce 예외(비-CoerceError) |
|
|
263
268
|
|
|
264
269
|
```json
|
|
265
270
|
// locales/en.json — 앱이 검증 문안을 로케일별로 준다(i18next {{max}} 보간).
|
|
@@ -337,7 +342,10 @@ declare module 'gaonjs/vue' {
|
|
|
337
342
|
interface GaonSharedProps { locale: string; theme: string }
|
|
338
343
|
}
|
|
339
344
|
// 페이지에서
|
|
340
|
-
const
|
|
345
|
+
const shared = useShared() // 객체로 잡으면 반응형 — 매 접근이 최신 props 를 읽는다
|
|
346
|
+
shared.locale // 로그인/로그아웃·플래시로 서버가 새 값을 주면 즉시 갱신
|
|
347
|
+
// 구조분해 const { locale } = useShared() 는 그 시점 값을 한 번 읽는 **스냅샷**(반응성 없음).
|
|
348
|
+
// 반응성이 필요하면 객체(shared.locale)로 접근한다(pageProps 결정 99 와 같은 계약).
|
|
341
349
|
```
|
|
342
350
|
|
|
343
351
|
- **코어 3종(currentUser·csrf·flash)은 예약** — `sharedProps` 가 이 이름을 반환하면
|
|
@@ -411,7 +419,9 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
411
419
|
|
|
412
420
|
- 세션 쿠키가 기본, JWT 는 **API 앱 전용 옵션** (v0.11 확정).
|
|
413
421
|
- 세션은 앱별 완전 분리 (Fastify 캡슐화 스코프): 쿠키 이름(`<app>_sid`) ·
|
|
414
|
-
서명 secret · Redis 키 prefix
|
|
422
|
+
서명 secret · Redis 키 prefix 가 앱 단위로 갇힌다. (쿠키 path 는 `/` 고정 —
|
|
423
|
+
프리픽스·서브도메인 양쪽 접근에 쿠키가 실리려면 정적 path 가 `/` 여야 한다 ·
|
|
424
|
+
결정 142. 분리는 위 세 축으로 완성된다.)
|
|
415
425
|
- 스캐폴드는 `gaon g auth` — 로그인/회원가입 컨트롤러·페이지·라우트 일습.
|
|
416
426
|
- **로그인 필요 액션의 정답 = `this.requireAuth()`** (결정 57). notFound 처럼
|
|
417
427
|
예외로 마감하지만, **`if` 가드 자체가 없다**는 게 핵심:
|
|
@@ -568,10 +578,16 @@ export default controller({
|
|
|
568
578
|
| 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
|
|
569
579
|
| 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
|
|
570
580
|
| 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내) |
|
|
581
|
+
| 결정 64 | 폼 API 는 `gaonjs/vue` 의 `useForm`·`router` 뿐 — 로그아웃 등 DELETE 는 `router.delete()`(`@inertiajs/vue3` 직접 import 금지 · `Inertia.post()` 유령 API 아님) |
|
|
582
|
+
| 결정 122 | 관계·hidden 값이 render 경계 `serializeProps` 를 넘어 새지 않는다 — hidden 컬럼 제외 유지(§4.2) |
|
|
583
|
+
| 결정 165 | 세션/CSRF 실패·415(지원 안 되는 Content-Type)를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 으로 앱을 깨지 않는다(§4.1 · 지원 타입 `application/json`·`multipart/form-data`) |
|
|
584
|
+
| 결정 183 | 검증 사유 로케일화 — 안정 코드 + 예약 namespace `validation.<code>` 로 요청 로케일 번역(미제공 시 내장 fallback · §4.1) |
|
|
585
|
+
| 결정 253 | hidden 마커 = 열거 가능한 심볼 → `render(page, { ...user })` spread 우회로도 hidden 값이 안 샌다(§4.2) |
|
|
586
|
+
| 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§4.4 auth) |
|
|
571
587
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|
|
572
588
|
|
|
573
589
|
## `@gaonjs/seal` 켠 앱
|
|
574
590
|
|
|
575
591
|
`app.config seal: true` 면 그 앱의 wire(요청/응답 JSON + 최초 문서 data-page)가 봉인된다 — 컨트롤러·라우트·
|
|
576
592
|
`this.params`·`api()` 코드는 한 줄도 안 바뀐다. **정본은 `agents/seal.md`**(켜는 법·main.ts 배선·`except`·
|
|
577
|
-
자동 제외·fail-closed 403).
|
|
593
|
+
자동 제외·fail-closed = 평문 통과 금지 · 기본 403 · 과대 페이로드는 413).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.3",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/async": "0.15.
|
|
31
|
-
"@gaonjs/config": "0.
|
|
30
|
+
"@gaonjs/async": "0.15.3",
|
|
31
|
+
"@gaonjs/config": "0.18.1",
|
|
32
32
|
"@gaonjs/core": "0.2.4",
|
|
33
|
-
"@gaonjs/data": "0.17.
|
|
34
|
-
"@gaonjs/i18n": "0.2.
|
|
35
|
-
"@gaonjs/mail": "0.3.
|
|
36
|
-
"@gaonjs/web": "0.20.
|
|
33
|
+
"@gaonjs/data": "0.17.3",
|
|
34
|
+
"@gaonjs/i18n": "0.2.4",
|
|
35
|
+
"@gaonjs/mail": "0.3.3",
|
|
36
|
+
"@gaonjs/web": "0.20.5"
|
|
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})\""
|