@gaonjs/cli 0.55.0 → 0.57.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 (33) hide show
  1. package/dist/commands/dev.js +22 -3
  2. package/dist/commands/test.js +28 -4
  3. package/dist/doctor/auth-wiring.js +5 -2
  4. package/dist/doctor/channel-collision.d.ts +9 -0
  5. package/dist/doctor/channel-collision.js +119 -0
  6. package/dist/doctor/dotenv-node-env.d.ts +5 -0
  7. package/dist/doctor/dotenv-node-env.js +68 -0
  8. package/dist/doctor/fixers/index.d.ts +1 -1
  9. package/dist/doctor/fixers/index.js +11 -1
  10. package/dist/doctor/types.d.ts +1 -1
  11. package/dist/doctor.d.ts +10 -2
  12. package/dist/doctor.js +50 -3
  13. package/dist/generate.d.ts +5 -0
  14. package/dist/generate.js +11 -3
  15. package/dist/index.js +7 -5
  16. package/dist/nodeEnv.d.ts +23 -0
  17. package/dist/nodeEnv.js +26 -0
  18. package/dist/scaffold/controller.js +3 -1
  19. package/dist/scaffold/page.js +6 -4
  20. package/dist/serve.js +13 -1
  21. package/dist/templates/project/AGENTS.md.tpl +34 -5
  22. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  23. package/dist/templates/project/agents/async.md.tpl +20 -7
  24. package/dist/templates/project/agents/data.md.tpl +65 -11
  25. package/dist/templates/project/agents/frontend.md.tpl +19 -9
  26. package/dist/templates/project/agents/i18n.md.tpl +17 -4
  27. package/dist/templates/project/agents/realtime.md.tpl +105 -15
  28. package/dist/templates/project/agents/seal.md.tpl +19 -7
  29. package/dist/templates/project/agents/security.md.tpl +13 -1
  30. package/dist/templates/project/agents/storage.md.tpl +21 -9
  31. package/dist/templates/project/agents/testing.md.tpl +58 -0
  32. package/dist/templates/project/agents/web.md.tpl +157 -15
  33. package/package.json +6 -6
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @gaonjs/cli · NODE_ENV 기본값 (결정 430)
3
+ *
4
+ * **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production.
5
+ * 사용자가 명시한 값(셸 env·`.env`)은 항상 존중한다(로컬 프로덕션 확인 `NODE_ENV=production
6
+ * gaon dev` 같은 정당 케이스). 부팅 진입점이 loadDotEnv 직후, 다른 배선보다 먼저 부른다.
7
+ *
8
+ * 이게 없으면 두 방향으로 샌다:
9
+ * · dev — `gaon dev` 가 vite 를 **자기 프로세스 안에서** 부르는데(dev/build.ts), vite 의
10
+ * resolveConfig(build)가 NODE_ENV 미설정 시 process.env.NODE_ENV 에 'production' 을 꽂는다.
11
+ * 이후 파일 저장으로 재기동된 serve·work 자식이 오염된 production 을 상속해 prod 게이트에
12
+ * 걸려 즉사한다(최초 자식은 vite 빌드 전에 떠서 멀쩡 — 저장 한 번에 죽는 split-brain).
13
+ * · serve — NODE_ENV 미설정 운영 배포는 어느 쪽도 아니어서 prod 게이트(세션 dev 플레이스홀더
14
+ * secret 거부·쿠키 Secure 기본)가 **안 걸린 채** 조용히 떴다(fail-loud 우회 구멍).
15
+ */
16
+ /**
17
+ * NODE_ENV 가 비어 있으면 `mode` 로 채운다. 이미 값이 있으면 건드리지 않는다.
18
+ *
19
+ * 빈 문자열은 **미설정으로 본다** — `.env` 의 `NODE_ENV=` 만 적힌 흔한 실수를 걸러야 한다
20
+ * (core env.ts `raw()` 와 같은 규약). `??=` 로는 '' 가 통과하는데, vite 도 `!!process.env.NODE_ENV`
21
+ * 로 판정하므로 '' 를 남겨두면 결국 vite 가 production 을 꽂아 같은 사고가 난다.
22
+ */
23
+ export declare function defaultNodeEnv(mode: 'development' | 'production'): void;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @gaonjs/cli · NODE_ENV 기본값 (결정 430)
3
+ *
4
+ * **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production.
5
+ * 사용자가 명시한 값(셸 env·`.env`)은 항상 존중한다(로컬 프로덕션 확인 `NODE_ENV=production
6
+ * gaon dev` 같은 정당 케이스). 부팅 진입점이 loadDotEnv 직후, 다른 배선보다 먼저 부른다.
7
+ *
8
+ * 이게 없으면 두 방향으로 샌다:
9
+ * · dev — `gaon dev` 가 vite 를 **자기 프로세스 안에서** 부르는데(dev/build.ts), vite 의
10
+ * resolveConfig(build)가 NODE_ENV 미설정 시 process.env.NODE_ENV 에 'production' 을 꽂는다.
11
+ * 이후 파일 저장으로 재기동된 serve·work 자식이 오염된 production 을 상속해 prod 게이트에
12
+ * 걸려 즉사한다(최초 자식은 vite 빌드 전에 떠서 멀쩡 — 저장 한 번에 죽는 split-brain).
13
+ * · serve — NODE_ENV 미설정 운영 배포는 어느 쪽도 아니어서 prod 게이트(세션 dev 플레이스홀더
14
+ * secret 거부·쿠키 Secure 기본)가 **안 걸린 채** 조용히 떴다(fail-loud 우회 구멍).
15
+ */
16
+ /**
17
+ * NODE_ENV 가 비어 있으면 `mode` 로 채운다. 이미 값이 있으면 건드리지 않는다.
18
+ *
19
+ * 빈 문자열은 **미설정으로 본다** — `.env` 의 `NODE_ENV=` 만 적힌 흔한 실수를 걸러야 한다
20
+ * (core env.ts `raw()` 와 같은 규약). `??=` 로는 '' 가 통과하는데, vite 도 `!!process.env.NODE_ENV`
21
+ * 로 판정하므로 '' 를 남겨두면 결국 vite 가 production 을 꽂아 같은 사고가 난다.
22
+ */
23
+ export function defaultNodeEnv(mode) {
24
+ if (!process.env.NODE_ENV)
25
+ process.env.NODE_ENV = mode;
26
+ }
@@ -18,6 +18,8 @@
18
18
  * @param app 대상 앱 폴더(apps/<app>/).
19
19
  */
20
20
  export function controllerScaffold(names, app) {
21
+ // 라우트 키·api() 키는 **앱 접두**를 포함한다(결정 55 · 'app:controller#action') —
22
+ // 접두 없는 'posts#count' 는 멀티앱에서 해상되지 않아 주석 그대로 복사하면 컴파일이 깨진다.
21
23
  const { pascal, plural } = names;
22
24
  const lines = [
23
25
  `// ${plural} 컨트롤러 — gaon g controller (M9-B).`,
@@ -35,7 +37,7 @@ export function controllerScaffold(names, app) {
35
37
  ` },`,
36
38
  ``,
37
39
  ` // GET /${plural}/count.json — JSON 액션 (errata E-3).`,
38
- ` // 반환값 = 응답. api('${plural}#count') 클라이언트가 Serialized<> 로 받는다.`,
40
+ ` // 반환값 = 응답. api('${app}:${plural}#count') 클라이언트가 Serialized<> 로 받는다.`,
39
41
  ` // this.params() 안전 규칙: 라우트 > body > query (errata E-3 §5.1).`,
40
42
  ` async count() {`,
41
43
  ` return { total: await ${pascal}.count() }`,
@@ -22,10 +22,12 @@ export function pageScaffold(pagePath, app) {
22
22
  const segments = trimmed.split('/').filter(Boolean);
23
23
  const last = segments[segments.length - 1];
24
24
  const parent = segments[segments.length - 2] ?? last;
25
- // 라우트 키 기본값 — 앱 접두(결정 55) + 폴더=리소스명(복수·소문자) +
26
- // 파일=액션명(소문자). 앱 네임스페이스가 있어야 멀티앱에서 전역 GaonRouteMap
27
- // 충돌이 없다.
28
- const routeKey = `${app}:${toCamel(parent).toLowerCase()}#${toCamel(last).toLowerCase()}`;
25
+ // 라우트 키 기본값 — 앱 접두(결정 55) + 폴더=리소스명(컨트롤러 파일명) +
26
+ // 파일=액션명. 앱 네임스페이스가 있어야 멀티앱에서 전역 GaonRouteMap 충돌이 없다.
27
+ // camelCase 를 **평탄화하지 않는다**: 컨트롤러 파일명·액션명 관례가 camelCase 라
28
+ // (`blogPosts.ts` · `editForm`) 소문자로 뭉개면 `blogposts#editform` 처럼 존재하지
29
+ // 않는 키가 나와 pageProps 가 해상되지 않는다(gaon g page BlogPosts/EditForm 실측).
30
+ const routeKey = `${app}:${toCamel(parent)}#${toCamel(last)}`;
29
31
  const filePath = `apps/${app}/pages/${trimmed}.vue`;
30
32
  const lines = [
31
33
  `<script setup lang="ts">`,
package/dist/serve.js CHANGED
@@ -18,6 +18,7 @@ import { availableParallelism } from 'node:os';
18
18
  import { loadDotEnv } from '@gaonjs/core';
19
19
  import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
20
20
  import { registerTsResolve } from './tsResolve.js';
21
+ import { defaultNodeEnv } from './nodeEnv.js';
21
22
  import { parsePort } from './port.js';
22
23
  import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
23
24
  import { createReforkSupervisor, reforkPolicyFromEnv } from './cluster.js';
@@ -152,6 +153,14 @@ export async function runServeCommand(opts = {}) {
152
153
  // .ts 상대 import 해석기 등록 (사용자 gaon.config.ts, apps/* 로드에 필요).
153
154
  registerTsResolve();
154
155
  loadDotEnv(cwd);
156
+ // 결정 430: 모드는 명령이 정한다 — `gaon serve` 는 production 이 기본이고, `gaon dev` 가
157
+ // 스폰한 자식(--dev)만 development. 이전엔 NODE_ENV 미설정 serve 가 어느 쪽도 아니어서
158
+ // 운영 배포에서 prod 게이트가 통째로 **안 걸렸다**: 세션 dev 플레이스홀더 secret 거부
159
+ // (결정 255)·쿠키 Secure 기본(결정 295)·락 백엔드 in-memory 폴백 차단(결정 88①)이 전부
160
+ // `NODE_ENV === 'production'` 판정에 걸려 있다 — fail-loud 를 무설정으로 우회하던 구멍.
161
+ // dev 자식은 부모의 development 를 스냅샷으로 상속하므로 여기서 다시 결정되지 않는다.
162
+ const explicitNodeEnv = process.env.NODE_ENV || undefined;
163
+ defaultNodeEnv(opts.dev ? 'development' : 'production');
155
164
  const emit = (e) => {
156
165
  if (json)
157
166
  process.stdout.write(JSON.stringify(e) + '\n');
@@ -201,7 +210,10 @@ export async function runServeCommand(opts = {}) {
201
210
  // 수정이 조용히 반영 안 되는 혼란(첫 실사용 관측)을 막으려 한 줄 안내한다.
202
211
  // dev 자식(gaon dev · opts.dev)은 이미 감시하므로 그때는 안내하지 않고,
203
212
  // production 은 운영 로그 소음을 막으려 출력하지 않는다. json 은 파싱 안전상 제외.
204
- if (!json && !opts.dev && process.env.NODE_ENV !== 'production') {
213
+ // 결정 430: 판정은 **명시된** NODE_ENV 한다 — 위에서 채운 기본값(production)으로
214
+ // 보면 로컬에서 그냥 `gaon serve` 한 사람(이 안내가 가장 필요한 대상)에게서 안내가
215
+ // 사라진다. 운영은 NODE_ENV=production 을 명시하므로 침묵 의도는 그대로다.
216
+ if (!json && !opts.dev && explicitNodeEnv !== 'production') {
205
217
  process.stdout.write(' ℹ serve 는 빌드된 번들을 서빙하며 코드 변경을 감시하지 않습니다 → 개발 중이면 `gaon dev` 를 쓰세요.\n');
206
218
  }
207
219
  }
@@ -58,9 +58,11 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
58
58
  1. **TypeScript 전용 · 함수/객체 스타일.** JS 파일 추가 금지, 클래스형·
59
59
  데코레이터 금지 — `model()`·`controller()`·`job()`·`service()`·
60
60
  `channel()` 함수형 API 만.
61
- 2. **파사드 import 만.** 프레임웍 심볼은 `gaonjs/*` (`gaonjs/web`·
62
- `gaonjs/data`·`gaonjs/vue`·`gaonjs/async`·`gaonjs/service
63
- `gaonjs/testing`)에서 import 한다. `@gaonjs/*`(내부 스코프)·
61
+ 2. **파사드 import 만.** 프레임웍 심볼은 `gaonjs` 와 그 하위 경로에서
62
+ import 한다 — 전체 목록: `gaonjs`·`gaonjs/data`·`gaonjs/web
63
+ `gaonjs/vue`·`gaonjs/async`·`gaonjs/config`·`gaonjs/service`·
64
+ `gaonjs/mail`·`gaonjs/storage`·`gaonjs/i18n`·`gaonjs/env`·
65
+ `gaonjs/log`·`gaonjs/testing`. `@gaonjs/*`(내부 스코프)·
64
66
  `@inertiajs/vue3`(어댑터 내부 의존)는 앱 코드에서 직접 import 금지.
65
67
  (설치명 `gaonjs` · CLI 명령 `gaon` — errata E-1.)
66
68
  3. **의존 방향 4규칙** (doctor 강제): ① 앱→`domain/` 허용 ②
@@ -111,7 +113,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
111
113
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
112
114
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
113
115
 
114
- ### 2.2 `gaon doctor` 검사 28
116
+ ### 2.2 `gaon doctor` 검사 30
115
117
 
116
118
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
117
119
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -141,6 +143,8 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
141
143
  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)
142
144
  27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
143
145
  28. `render-return` — 액션이 `this.render`/`this.redirect`/`this.json` 을 호출만 하고 `return` 하지 않음 = 응답이 버려져 조용히 204(백지) — `return this.render(...)` 로 고치라 (결정 340 · 경고)
146
+ 29. `channel-collision` — 두 앱이 **같은 이름의 채널**을 각각 정의 = **에러**. 채널 이름은 전역이다(브로드캐스트 subject `gaon.chan.<이름>`·프레즌스 키에 앱 프리픽스 없음) — 한 앱의 broadcast 가 다른 앱 연결로 팬아웃되고 접속자 목록이 병합되며, 두 정의의 `authorize` 가 갈리면 공개 쪽 규칙으로 메시지가 샌다. 앱마다 이름을 분리하거나(클라이언트 `useChannel` 인자도 함께), 일부러 공유하는 채널이면 정의를 `shared/channels/<이름>.ts` 하나에 두고 각 앱 채널 파일에서 재수출하라(재수출은 통과 · 정의 하나 = 인가 규칙 하나) — 잡·리스너의 동명 등록 throw(결정 271)와 같은 계열의 정적 검사 (`agents/realtime.md` §2)
147
+ 30. `dotenv-node-env` — 공유 `.env`(·`.env.local`·`.env.example`)에 `NODE_ENV` 가 설정됨 = **경고**. **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production(결정 430). 이 파일들은 개발·운영이 함께 읽으므로 값을 박으면 모드가 양쪽으로 샌다: `production` 이면 `gaon dev` 가 쿠키 Secure·dev 플레이스홀더 secret 거부로 죽고, `development` 면 운영 `gaon serve` 에서 프로덕션 안전장치(플레이스홀더 secret 거부·쿠키 Secure·락 in-memory 폴백 차단)가 통째로 꺼진다(**부팅은 green, 보안만 꺼짐**). `.env` 에서 그 줄을 지우고, 모드별 값이 필요하면 `.env.development`/`.env.production` 오버레이에, 일회성이면 명령 앞에 붙인다(`NODE_ENV=production gaon serve`) — 모드별 오버레이 파일은 검사 대상이 아니다 (결정 430)
144
148
 
145
149
  ## 3. 로직 배치 One Way 판단표
146
150
 
@@ -201,7 +205,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
201
205
  ```bash
202
206
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
203
207
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
204
- gaon doctor # 정적 검사 28종 (§2.2)
208
+ gaon doctor # 정적 검사 30종 (§2.2)
205
209
  ```
206
210
 
207
211
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -212,6 +216,7 @@ gaon doctor # 정적 검사 28종 (§2.2)
212
216
  | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**serve·work·hub 자동 기동**·**코드 변경 감시·재시작** · 결정 211) |
213
217
  | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
214
218
  | `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` · `g auth --app <앱> --public` = 비-web 앱에 공개 회원가입(`/registration/new`)을 opt-in(기본: web=공개·비-web=역할 게이트 · 결정 155) |
219
+ | `gaon g auth --jwt --app <앱>` | **API(JWT) 앱 변형** — 이 명령 **하나**가 앱 폴더째 만든다(토큰 컨트롤러 3종 + `strategy:'jwt'` app.config + `<APP>_JWT_SECRET` 시드 · 페이지·UI 킷·회원가입 없음). `gaon g app` 을 먼저 돌리지 않는다 — 이미 있는 app.config 는 자동 배선을 못 해 exit 1 이고, 쓰지 않는 Vue 프론트가 남는다. web 앱은 세션이 정본이라 `--jwt` 불가 (결정 337·338) |
215
220
  | `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
216
221
  | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
217
222
  | `gaon check` / `test` / `doctor` | 검증 루프 |
@@ -228,6 +233,30 @@ gaon doctor # 정적 검사 28종 (§2.2)
228
233
  개발 중이면 `gaon dev`(감시·재시작·`.gaon` 재생성 통합)를 쓴다. `serve` 는
229
234
  비-production 부팅 시 이 안내를 한 줄 출력한다.
230
235
 
236
+ **`NODE_ENV` — 모드는 명령이 정한다** (결정 430). `gaon dev` 는 `development`,
237
+ `gaon serve` 는 `production` 을 기본으로 잡는다(이미 설정돼 있으면 그 값을 존중 —
238
+ `NODE_ENV=production gaon serve`·`NODE_ENV=production gaon dev` 는 그대로 동작).
239
+ **`.env` 에 `NODE_ENV` 를 쓰지 않는다** — `.env`·`.env.local`·`.env.example` 은 개발과
240
+ 운영이 함께 읽는 파일이라 한쪽 모드가 반대쪽으로 샌다(운영이 development 로 뜨면
241
+ 플레이스홀더 secret 거부·쿠키 Secure 같은 프로덕션 안전장치가 부팅 green 인 채로 꺼진다).
242
+ 모드별로 다른 값이 필요하면 `.env.development`/`.env.production` 오버레이에 둔다
243
+ (doctor `dotenv-node-env` 가 검사한다 · §2.2).
244
+
245
+ > **이행 안내(동작 변경)** — 종전에는 `NODE_ENV` 를 안 주면 `gaon serve` 가 **어느 모드도
246
+ > 아닌 상태**로 떠서 프로덕션 안전장치가 하나도 안 걸렸다. 이제는 `production` 이므로,
247
+ > **로컬에서 `gaon build && gaon serve` 로 확인하던 사람**은 다음 둘 중 하나가 보인다:
248
+ > ① 세션 secret 이 스캐폴드 플레이스홀더면 **부팅이 exit 1** 로 거부된다(결정 255)
249
+ > ② 떠도 세션 쿠키가 `Secure` 라 **비-TLS(http://localhost) 로그인이 안 붙는다**(결정 295).
250
+ > 둘 다 "운영에서 켜졌어야 할 게 이제 켜진" 것이다 — 끄지 말고 아래로 옮긴다:
251
+ > - **로컬 확인이 목적이면 `gaon dev`**(또는 `gaon serve --dev`). 이게 정답 경로다.
252
+ > - **로컬에서 프로덕션 빌드를 그대로 보고 싶으면** 실 `SESSION_SECRET` 을 준다:
253
+ > `SESSION_SECRET=$(openssl rand -hex 32) gaon serve`. 쿠키 `Secure` 때문에 로그인까지
254
+ > 봐야 한다면 TLS 종단을 앞에 두거나 그 확인만 `gaon dev` 로 한다.
255
+ >
256
+ > **운영 로그 파이프라인 주의** — `gaon hub` 의 치명 오류는 이제 루트 로거로 나간다(결정 432).
257
+ > pino 기본 목적지가 **stdout** 이라, `2>` 로 stderr 만 수집하던 설정은 이 메시지를 놓친다
258
+ > (로거 확보에 실패한 경우에만 종전대로 stderr 로 떨어진다). stdout 을 함께 수집한다.
259
+
231
260
  **스케일링 구분** — `serve --workers N`(한 포트 · node:cluster 수직) vs 웹 인스턴스
232
261
  여러 대(각각 다른 `PORT` · 허브 뒤 수평) vs `work`(포트 없음 · 프로세스만)는 서로
233
262
  다르다. 워커 다중화 시 스케줄 발행=단일 리더 · 소비=워커 분산 계약은
@@ -86,7 +86,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
86
86
 
87
87
  ```bash
88
88
  gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
89
- gaon doctor # 정적 검사 28종 (상세 AGENTS §2.2)
89
+ gaon doctor # 정적 검사 30종 (상세 AGENTS §2.2)
90
90
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
91
91
  ```
92
92
 
@@ -39,6 +39,11 @@ await SendWelcomeMail.later(user.id) // domain/jobs/sendWelcomeMail.ts (§1)
39
39
 
40
40
  ```ts
41
41
  // 파생 효과 — 커밋 뒤에만 나가야 하는 발행은 서비스 afterCommit (§4 아웃박스).
42
+ // domain/services/registerUser.ts
43
+ import { service, afterCommit } from 'gaonjs/service' // service·afterCommit 는 같은 서브패스
44
+ import { User } from '../models/User.js'
45
+ import { ResizeAvatar } from '../jobs/resizeAvatar.js'
46
+
42
47
  export const RegisterUser = service(async (input: RegisterInput) => {
43
48
  const user = await User.create(input)
44
49
  afterCommit(() => ResizeAvatar.later(user.id)) // 커밋 성공 후에만 발행
@@ -89,18 +94,25 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
89
94
  유추가 충돌한다. 이제 **등록 시 throw**(조용한 덮어쓰기 = 발행이 엉뚱한
90
95
  핸들러로 가던 무신호 버그 봉합) — 각각 `name` 을 다르게 주거나 파일을 나눈다.
91
96
  리스너(`on()`)도 동형 — 같은 파일에 여럿 두면 `id` 를 다르게 준다(durable 충돌).
92
- - **옵션** — `queue`(기본 `'default'`) · `retries`(기본 3) ·
93
- `curve`(백오프 곡선 ms) · `jitter` · `concurrency`.
97
+ - **옵션(`JobOptions` 전부)** — `name` · `queue`(기본 `'default'`) · `retries`(기본 3) ·
98
+ `concurrency`(기본 1) + 백오프(`curve` 곡선 ms · `jitter` 기본 0.2). 이 6개가 전부다 —
99
+ **`maxDeliver` 는 잡 옵션이 아니라 워커 옵션**이다(아래).
94
100
  - **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
95
101
  `gaon jobs retry <id>` 로 조회·재적재한다(조회는 전량 배치 스캔 — 옛 레코드도
96
102
  상한 없이 찾아 재적재할 수 있다 · 결정 351).
97
103
  - **네이티브 재전달 소진도 DLQ 로 간다(결정 347).** 크래시 루프·미등록 잡(워커에
98
- `domain/jobs/` 파일이 배포되지 않음)이 재전달 상한(기본 25 · `maxDeliver`)을
104
+ `domain/jobs/` 파일이 배포되지 않음)이 재전달 상한(기본 25)을
99
105
  소진하면, 워커가 MAX_DELIVERIES advisory 를 받아 그 잡을 DLQ 로 이관한다 —
100
106
  이전엔 스트림에 무신호로 영구 잔류했다. 미등록 잡의 재전달 지연은 지수
101
107
  (1s→2s→…30s 포화)이고 잡 이름당 1회 경고를 남긴다(정상 롤링 배포 창은 통과).
102
108
  advisory 는 비영속이라 백스톱은 best-effort 다(소진 순간 워커가 전무하면 다음
103
109
  소진 때 회수).
110
+ - **`maxDeliver` 는 워커 옵션이다 — 잡별로 못 준다.** 재전달 상한은 `runWork()` 의
111
+ `maxDeliver`(= `runWorker()` 로 전달 · `packages/async/src/worker.ts`)이고 그 워커가
112
+ 소비하는 **모든 큐에 공통**으로 걸린다. `job(handler, { maxDeliver: … })` 같은 표면은
113
+ 없다(`JobOptions` 는 위 6개뿐 — 넘겨도 무시된다). 잡별로 조절 가능한 것은
114
+ `retries`(앱 레벨 재시도)뿐이고, `maxDeliver` 는 그 아래층인 **JetStream 네이티브
115
+ 재전달**(크래시 복구·미등록 잡 백스톱)의 상한이라 축이 다르다.
104
116
  - **큐 동시성은 큐별로 정확히 적용된다(결정 348)** — 다른 큐의 긴 잡이 이 큐의
105
117
  처리량을 깎지 않는다(잡별 `concurrency` 선언 = 그 큐의 실제 동시 처리 수).
106
118
  - **워커 복원력(결정 258)** — 재시도 재적재나 DLQ 이관을 하는 도중 NATS 가
@@ -190,7 +202,8 @@ await OrderPlaced.emit({ orderId: 1n })
190
202
 
191
203
  - 실패하면 백오프(잡과 같은 곡선 `[1s, 5s, 30s, 5m, 1h]`)로 재전달되고, 최대
192
204
  재전달(기본 6 · 최초 포함) 소진 시 **영구 폐기**된다. 즉 계속 실패하는 이벤트는
193
- **1시간 36분** 사라진다 `gaon work` 가 `✗ 이벤트 폐기` 로 신호한다
205
+ 최초 실패로부터 **약 1시간 5분**(1s+5s+30s+5m+1h = **1h05m36s** · 지터 ±20% 별도)
206
+ 뒤 사라진다 — `gaon work` 가 `✗ 이벤트 폐기` 로 신호한다
194
207
  (결정 308 · 이전엔 human 모드 무신호). **크래시 루프**(핸들러 throw 가 아니라
195
208
  프로세스가 ack 전에 반복 사망)로 소진돼도 MAX_DELIVERIES advisory 백스톱이
196
209
  같은 `✗ 이벤트 폐기` 신호를 낸다(결정 398 · 잡의 결정 347 동형 · advisory 는
@@ -476,7 +489,7 @@ async create() {
476
489
  - **스케줄 대상은 항상 잡 · 무인자** — `s.every('10m', async () => ...)` 인라인
477
490
  함수 금지. 인자 필수 잡은 컴파일 에러로 거부된다(결정 310 · §5).
478
491
  - **계속 실패하는 리스너는 이벤트를 잃는다** — 리스너는 DLQ 가 없어 재전달
479
- 소진(기본 6회 · 약 1h36m) 후 영구 폐기된다(§3 계약 · `gaon work` 가 `✗ 이벤트
492
+ 소진(기본 6회 · 약 1h05m36s) 후 영구 폐기된다(§3 계약 · `gaon work` 가 `✗ 이벤트
480
493
  폐기` 로 신호). 유실 불가 처리는 리스너에서 잡으로 넘긴다.
481
494
  - **커밋 전 발행 주의** — 트랜잭션 안에서 DB 확정 후에만 나가야 하는
482
495
  발행은 `afterCommit()` 또는 아웃박스로.
@@ -508,11 +521,11 @@ async create() {
508
521
  | 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
509
522
  | 결정 258 | 워커 소비 루프 복원력(§1) — 재시도/DLQ 발행이 NATS 순단으로 실패해도 큐 소비가 멈추지 않음(nak 재전달 백스톱 · 잡 유실 방지 · 실패 로그) |
510
523
  | 결정 306 | 아웃박스 발행 실패 행 격리(§4) — poison 행(페이로드 상한 초과 등)이 배치 전체를 세우지 않음 · 실패 행은 미발행 유지(유실 없음·재시도) · `relay-error` 이벤트로 관측 |
511
- | 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ 없음·~1h36m) 명문화 |
524
+ | 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ 없음 · 곡선 합 **1h05m36s** · 종전 "1h36m" 오기 정정) 명문화 |
512
525
  | 결정 310 | 스케줄 대상 잡 무인자 가드(§5) — 인자 필수 잡 등록을 컴파일 타임 거부(메서드 bivariance 로 통과해 `undefined` 인자 발화하던 구멍 차단) |
513
526
  | 결정 312 | 아웃박스 릴레이 env 튜닝(§4) — `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`(`GAON_WORKER_*` 대칭) |
514
527
  | 결정 346 | 아웃박스 2단계 publish(§4) — claim(`claimed_at`+SKIP LOCKED 짧은 tx) → 커밋 → tx 밖 publish → 표시 · NATS 지연의 DB 락 전파 제거 · claim 리스 60s(< dedupe 창) · 유실 0 |
515
- | 결정 347 | max_deliver 소진 DLQ 백스톱(§1) — MAX_DELIVERIES advisory → DLQ 이관(무신호 영구 잔류 봉합) · 미등록 잡 지수 nak(1s→30s 포화)+이름당 1회 경고 · `maxDeliver` 옵션 |
528
+ | 결정 347 | max_deliver 소진 DLQ 백스톱(§1) — MAX_DELIVERIES advisory → DLQ 이관(무신호 영구 잔류 봉합) · 미등록 잡 지수 nak(1s→30s 포화)+이름당 1회 경고 · 상한은 **워커 옵션** `runWork({ maxDeliver })`(기본 25 · 잡 옵션 아님 · 워커의 전 큐 공통) |
516
529
  | 결정 348 | 워커 큐별 동시성 게이트(§1) — 전역 inflight 비교가 낳던 교차 큐 간섭 제거(선언 `concurrency` = 실제 동시 처리) |
517
530
  | 결정 349 | 리스 갱신 순단 재시도 + every 위상 KV 보존(§5) — 키가 내 것이면 revision 동기화 재시도 후에만 revoke · `gaon_scheduler` KV 로 위상 이어받기(플래핑 기아 봉합) |
518
531
  | 결정 351 | DLQ 조회 배치 스캔(§1) — ordered 컨슈머 fetch 로 삭제 갭 서버 스킵 · findDlq 1000건 상한 제거(옛 레코드 retry 복원) |
@@ -166,6 +166,7 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
166
166
  export const logs = table('logs', {
167
167
  userId: t.belongsTo('users'),
168
168
  status: t.enum(['active', 'archived'] as const),
169
+ retries: t.int(),
169
170
  createdAt: t.datetime(),
170
171
  meta: t.jsonb<Record<string, unknown>>().index(), // 자동 gin
171
172
  ...t.timestamps(),
@@ -179,11 +180,14 @@ export const logs = table('logs', {
179
180
  { cols: ['status'], where: "status = 'active'" }, // partial
180
181
  { expr: "(meta->>'tenant')" }, // 표현식 인덱스(이름 자동 · 해시)
181
182
  ],
182
- check: [['positive_age', 'age > 0']], // [name, expr]
183
+ check: [['retries_non_negative', 'retries >= 0']], // [name, expr]
183
184
  })
184
185
  ```
185
186
 
186
187
  - 인덱스 원소가 **문자열 배열**이면 기존과 100% 동일(btree). **객체**면 `cols`·`expr`·`using`·`where`·`name`.
188
+ - `cols` 는 **선언한 컬럼명**을 그대로 쓰지만 `where`·`expr`·`check` 는 **raw SQL** 이다 —
189
+ camelCase 컬럼을 raw SQL 에서 참조할 때는 큰따옴표로 감싼다(`"createdAt" > now()`).
190
+ Postgres 는 따옴표 없는 식별자를 소문자로 접어 `createdat` 을 찾는다.
187
191
  - 이름 규약 `idx_<table>_<cols>` 는 method/partial 을 구분하지 않는다 — **같은 컬럼에 두 인덱스**
188
192
  (예: 컬럼 `.index({where})` + 테이블 레벨 `[['col']]`)를 선언하면 이름이 충돌해 **정의 시점에
189
193
  throw** 한다(결정 329). 테이블 레벨 객체의 `name` 으로 구분한다.
@@ -193,7 +197,7 @@ export const logs = table('logs', {
193
197
  안 잡혀 매 diff 마다 재생성 대상이 되므로 **정의 시점에 throw** 한다(결정 373). 인덱스
194
198
  이름은 **커넥션(스키마) 전역 유일**이라 서로 다른 테이블의 같은 명시 name 도 diff 진입에서
195
199
  throw 한다(결정 382).
196
- - **MySQL/MariaDB(legacy §4.5) 커넥션은 gin/brin/gist·partial·표현식 인덱스가 없다** — 선언하면
200
+ - **MySQL/MariaDB(legacy · `adapter: 'mysql'` · §7) 커넥션은 gin/brin/gist·partial·표현식 인덱스가 없다** — 선언하면
197
201
  `gaon db migrate` 가 **명확히 실패**한다(조용히 btree 로 떨구지 않음). 이런 인덱스는 main(postgres)에.
198
202
 
199
203
  **선언적 파티셔닝** (결정 277 · PostgreSQL · 대용량 로그/이벤트/감사):
@@ -211,10 +215,24 @@ export const logs = table('logs', {
211
215
  - 전략은 `range | list | hash`. **부모 테이블만 선언**한다 — 파티션 키는 PK 에 자동 편입된다
212
216
  (복합 PK `(id, createdAt)`). 개별 **자식 파티션은 스키마 밖**(시간에 따라 증식)이라 헬퍼로 관리한다:
213
217
  ```ts
214
- // domain/schedule.ts — 크론 잡으로 명시 실행(자동 마법 없음)
215
- import { rollMonthlyPartitions } from 'gaonjs/data'
216
- await rollMonthlyPartitions(db, { table: 'logs', ahead: 1, keep: 6 })
217
- // ahead=다가올 개월 미리 생성 · keep=6 이면 6개월 지난 파티션 파기(keep 없으면 파기 안 함)
218
+ // domain/jobs/rollLogPartitions.ts — 파티션 롤링은 잡으로 두고 스케줄에 얹는다(자동 마법 없음).
219
+ import { job } from 'gaonjs/async'
220
+ import { getConnection, rollMonthlyPartitions } from 'gaonjs/data'
221
+
222
+ export const RollLogPartitions = job(async () => {
223
+ // getConnection('키') 가 Kysely 인스턴스 — 인자 생략 = main(§7).
224
+ await rollMonthlyPartitions(getConnection(), { table: 'logs', ahead: 1, keep: 6 })
225
+ // ahead=다가올 개월 미리 생성 · keep=6 이면 6개월 지난 파티션 파기(keep 없으면 파기 안 함)
226
+ })
227
+ ```
228
+ ```ts
229
+ // domain/schedule.ts — 스케줄 대상은 항상 잡(무인자) · 리더 하나만 발화한다(agents/async.md §5).
230
+ import { schedule } from 'gaonjs/async'
231
+ import { RollLogPartitions } from './jobs/rollLogPartitions.js'
232
+
233
+ export default schedule((s) => {
234
+ s.daily.at('03:30', RollLogPartitions)
235
+ })
218
236
  ```
219
237
  저수준 헬퍼: `createRangePartition`·`createListPartition`·`createHashPartition`·`createDefaultPartition`·
220
238
  `dropPartition`·`listPartitions`. **retention(오래된 파티션 파기)은 절대 자동으로 하지 않는다** —
@@ -417,8 +435,36 @@ const rows = await Post.query()
417
435
 
418
436
  혼동 유발이라 이름 분리를 유지한다 (E-4 (i) 결정).
419
437
 
420
- ### 7. 멀티 DB 커넥션 (v0.15 §4.5)
438
+ ### 7. 멀티 DB 커넥션
439
+
440
+ 커넥션은 루트 `gaon.config.ts` 의 **고유 키**로 선언하고, 스키마가 `{ db: '키' }`
441
+ 로 그 커넥션에 붙는다(생략 = `main`).
442
+
443
+ ```ts
444
+ // gaon.config.ts — 세 어댑터를 나란히 선언한 모습(SQL 둘 + 문서형 하나).
445
+ import { defineConfig } from 'gaonjs/config'
446
+ import { env } from 'gaonjs/env'
421
447
 
448
+ export default defineConfig({
449
+ db: {
450
+ main: { adapter: 'postgres', url: env('DATABASE_URL') }, // 키 생략 시 붙는 기본 커넥션
451
+ legacy: { adapter: 'mysql', url: env('LEGACY_URL') }, // MySQL · MariaDB 공통
452
+ logs: { adapter: 'mongodb', url: env('MONGO_URL') }, // 문서형 — collection() (§7.1)
453
+ },
454
+ })
455
+ ```
456
+
457
+ - **`adapter` 는 리터럴 3종뿐** — `'postgres' | 'mysql' | 'mongodb'`. **MariaDB 는
458
+ `adapter: 'mysql'`** 로 선언한다(`'mariadb'` 라는 값은 없다 — 같은 와이어 프로토콜·같은
459
+ 드라이버를 쓴다). 그래서 이 문서가 "MySQL/MariaDB(legacy)" 라고 부르는 방언 차이는 전부
460
+ `adapter: 'mysql'` 커넥션 이야기다.
461
+ - **URL 형식** — postgres `postgres://user:pass@host:5432/db` · MySQL/MariaDB
462
+ `mysql://user:pass@host:3306/db`(`mariadb://` 스킴도 같은 어댑터로 해석된다) ·
463
+ mongodb `mongodb://host:27017/db`(**DB 명 필수** · §7.1). `url` 대신
464
+ `host`·`port`·`user`·`password`·`database` 를 따로 줘도 되고, `poolMax` 로 풀 상한을 준다.
465
+ - 스캐폴드 기본은 `main` 하나이며 env 미설정이면 배선하지 않는 삼항 형태다 — 커넥션을
466
+ 늘릴 때는 **참 분기 안에** 키를 추가한다(doctor 의 커넥션·관계 검사가 참 분기의 키를
467
+ 정적으로 읽는다 · 결정 135).
422
468
  - **키 생략 = main** — 기본 경로는 단일 DB 프로젝트와 완전히 같다.
423
469
  - **커넥션을 가로지르는 `belongsTo`·역방향 관계는 금지** — SQL 조인은
424
470
  커넥션을 못 넘는다. doctor 의 **schema-relations** 검사(결정 134)가
@@ -478,12 +524,20 @@ SQL `model()`(Kysely) 옆에 문서형 동사 `collection()` 을 **Mongoose**
478
524
  로그·이벤트·감사·분석처럼 **문서·유연 스키마·대량 append** 용도다. **`model()` 은
479
525
  SQL 전용 · `collection()` 은 문서형** — 한 동사가 두 세계를 처리하지 않는다(The One Way).
480
526
 
481
- 문서형은 **opt-in 설치**다(SQL 전용 프로젝트는 mongoose 받지 않는다):
527
+ **추가로 설치할 것은 `mongoose` 하나뿐이다.** `@gaonjs/adapter-mongo` 파사드
528
+ `gaonjs` 의 정규 의존이라 이미 설치돼 있고(그래서 `gaonjs/data` 가 `collection`·
529
+ `mongoSchema` 를 재수출한다), 실제 드라이버인 `mongoose` 만 **optional peer** 라
530
+ SQL 전용 프로젝트는 받지 않는다 — 문서형을 쓰는 프로젝트만 명시 설치한다:
482
531
 
483
532
  ```bash
484
- npm i @gaonjs/adapter-mongo mongoose
533
+ npm i mongoose
485
534
  ```
486
535
 
536
+ > `@gaonjs/adapter-mongo` 를 앱 의존으로 **따로 적지 말 것** — 파사드가 exact-pin 으로
537
+ > 끌고 오는 버전과 앱이 적은 range 가 갈리면 설치 트리에 어댑터가 둘로 갈라져
538
+ > (레지스트리 split) 커넥션 레지스트리가 서로 안 보인다. 설치 명령은 프로젝트의
539
+ > 패키지 매니저를 따른다(`pnpm add mongoose`·`yarn add mongoose` 동형).
540
+
487
541
  ```ts
488
542
  // domain/schema/auditLog.ts — mongoSchema() 는 진짜 Mongoose Schema 를 반환한다.
489
543
  // 정본 패턴(결정 288·332): Doc + Methods + Model 인터페이스를 선언하고 제네릭 3개를
@@ -950,7 +1004,7 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
950
1004
  belongsTo 를 떼도 컬럼 shape(bigint)가 같아 감지되지 않는다(FK 가 조용히 미생성/잔존).
951
1005
  기존 컬럼에 FK 를 걸려면 손작성 마이그로 `ADD CONSTRAINT … FOREIGN KEY` 를 쓴다.
952
1006
  이 제약·기본값 diff 는 postgres 커넥션 기준이다
953
- (mysql=legacy §4.5 aux introspect 미지원 → 이 diff 생략). legacy(mysql/mariadb)
1007
+ (mysql=legacy §7 aux introspect 미지원 → 이 diff 생략). legacy(mysql/mariadb)
954
1008
  introspection 은 `char(n)`→uuid·`longtext`→jsonb **휴리스틱 매핑**을 쓴다(MariaDB 가
955
1009
  uuid/json 을 그 물리 타입으로 저장하는 왕복 정합 · 결정 273 Bug B) — Gaon 스키마가 만든
956
1010
  DB 에선 정확하지만, **기존(외부) DB 의 진짜 char/longtext 컬럼**은 uuid/jsonb 로 오인돼
@@ -964,7 +1018,7 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
964
1018
 
965
1019
  - **시그니처**: `seed(fn: () => Promise<void> | void): SeedDef` — `gaonjs/data`
966
1020
  에서 import 한다. 본문(`fn`)은 **모델을 그대로** 쓴다 — 모델이 커넥션을 자동
967
- 바인딩하므로(§4.5·§7) 시드는 커넥션을 몰라도 된다. `gaon db seed` 는 선언된
1021
+ 바인딩하므로(§7) 시드는 커넥션을 몰라도 된다. `gaon db seed` 는 선언된
968
1022
  **전 SQL 커넥션을 등록하고 시드를 1회 실행**한다(결정 367 — 여러 커넥션의
969
1023
  모델을 한 시드에서 섞어 써도 된다 · 문서형(mongodb) 커넥션은 열지 않는다).
970
1024
  - **멱등하게 짠다** — 시드는 재적재에 자주 쓰이므로 여러 번 돌려도 안전해야
@@ -106,7 +106,8 @@ const props = pageProps<'web:posts#index'>()
106
106
  import { api } from 'gaonjs/vue' // 파사드 · 서브패스 X
107
107
 
108
108
  async function search(q: string) {
109
- // 첫 인자 = 'controller#action' 라우트 키, 번째 = 라우트 파라미터 + 쿼리/바디
109
+ // 첫 인자 = 라우트 키 '<app>:<controller>#<action>' **앱 접두 필수**(pageProps 같은 · 결정 55).
110
+ // 두 번째 = 라우트 파라미터 + 쿼리/바디.
110
111
  const { results } = await api('web:posts#search', { q })
111
112
  return results
112
113
  }
@@ -116,6 +117,11 @@ async function search(q: string) {
116
117
  - **시그니처** — `api(key, params?, opts?)`. 제네릭 타입 인자를 직접
117
118
  붙이지 않는다 — `key` 값 자체가 `keyof GaonRouteMap` 으로 좁혀져
118
119
  반환 타입을 결정한다 (`packages/vue/src/api.ts`).
120
+ - **라우트 키에 앱 접두를 빠뜨리지 않는다** — `'posts#search'`(✗) 가 아니라
121
+ `'web:posts#search'`(○)다. 비-web 앱은 그 앱 이름을 쓴다(`'admin:posts#search'`).
122
+ **URL 프리픽스는 손으로 붙이지 않는다** — 매니페스트(`.gaon/routes.manifest.ts` ·
123
+ 결정 127)가 앱 프리픽스를 포함한 최종 URL(`/admin/posts`)을 들고 있어 api() 가
124
+ 그대로 친다. 접두를 빠뜨리면 키가 라우트 맵에 없어 **컴파일 에러**다(런타임 404 아님).
119
125
  - **params** — 라우트에 `:id` 같은 자리표시자가 있으면 거기서 채우고,
120
126
  남는 값은 GET 이면 쿼리스트링, 그 외 메서드는 JSON 본문으로 실린다
121
127
  (서버 `this.params` 우선순위와 대칭 · `agents/web.md` §3).
@@ -357,7 +363,7 @@ import PageShell from '@shared/components/ui/PageShell.vue'
357
363
  ```vue
358
364
  <script setup lang="ts">
359
365
  import Pagination from '@shared/components/ui/Pagination.vue'
360
- import { router } from 'gaonjs/vue'
366
+ import { pageProps, router } from 'gaonjs/vue'
361
367
  const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
362
368
  function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
363
369
  </script>
@@ -414,22 +420,24 @@ if (env.dev) console.log(env.mode) // 내장: dev·prod·mode·baseUrl(camelCa
414
420
  ## 정본 예시
415
421
 
416
422
  ```vue
417
- <!-- apps/web/pages/Posts/Index.vue — pageProps + api + string id key -->
423
+ <!-- apps/web/pages/Posts/Index.vue — Head + pageProps + api + string id key -->
418
424
  <script setup lang="ts">
419
425
  import { ref } from 'vue'
420
- import { pageProps, api } from 'gaonjs/vue'
426
+ import { Head, pageProps, api } from 'gaonjs/vue'
421
427
  import PostCard from '../../components/PostCard.vue'
422
428
 
423
429
  const props = pageProps<'web:posts#index'>()
424
430
  const results = ref<Awaited<ReturnType<typeof runSearch>>>([])
425
431
 
426
432
  async function runSearch(q: string) {
427
- const res = await api('web:posts#search', { q })
433
+ const res = await api('web:posts#search', { q }) // 라우트 키는 '<app>:<ctrl>#<action>'
428
434
  return res.results
429
435
  }
430
436
  </script>
431
437
 
432
438
  <template>
439
+ <!-- 문서 <title> 은 Head 로만 — document.title 수동 조작 금지(결정 271 · §1) -->
440
+ <Head title="글 목록" />
433
441
  <div>
434
442
  <!-- 컨트롤러가 String(p.id) 정규화 → :key 에 그대로 (결정 37) -->
435
443
  <PostCard v-for="post in props.posts" :key="post.id" :title="post.title" />
@@ -471,12 +479,14 @@ async function runSearch(q: string) {
471
479
  URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클·**자동 재연결**을
472
480
  재구현하다 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에. 소켓이 끊기면
473
481
  useChannel 이 지수 백오프로 **자동 재접속**하고 프레즌스를 재동기한다(결정 128 · 기본
474
- 켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401
475
- 재연결 안 함). 손 WebSocket 재연결 루프를 짜지 말 것. 결정 303: `send()` 는 소켓이
482
+ 켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 · 종단 close code
483
+ **4401**(authorize 거부)·**4500**(seal 개봉 실패)만 재연결 안 함). 손 WebSocket 재연결
484
+ 루프를 짜지 말 것. 결정 303: `send()` 는 소켓이
476
485
  OPEN 이 아니면 보내지 않고 `false` 를 반환한다(큐잉 없음 — 유실 불가 송신은 반환값
477
486
  확인). 컴포넌트 **밖**에서 부르면 즉시 접속되지만 자동 정리가 없어 호출자가
478
487
  `close()` 를 책임진다(기본 배치는 setup 안). 오래 사는 채널은 `maxMessages` 로
479
- `messages` 상한을 잡는다(`agents/realtime.md` §4).
488
+ `messages` 상한을 잡는다. 접속자 목록(`members`)은 **드롭·종료에 비워지지 않으므로**
489
+ `status` 를 함께 봐서 렌더한다(옵션·콜백 전체 표는 `agents/realtime.md` §4).
480
490
  - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
481
491
  중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
482
492
  - **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
@@ -523,7 +533,7 @@ async function runSearch(q: string) {
523
533
  | 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
524
534
  | 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
525
535
  | 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
526
- | 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §7) |
536
+ | 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §4) |
527
537
  | 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
528
538
  | 결정 299 | 타입 브리지 PropsOf 정정 — 유니온 분배(조건부 redirect 혼합 액션의 never 붕괴 봉합) + `this.json(data)` 언랩(`{json,status}` 래퍼 타입 거짓 봉합 · `JsonResult<T>` 제네릭) (§2) |
529
539
  | 결정 300 | pageProps/useShared 부팅 전 접근 가드(수리 안내) + setup-only 근거 정정(usePage=모듈 싱글턴 · inject 아님 · 실측) (§1·알려진 함정) |
@@ -14,13 +14,21 @@
14
14
  로케일이 없어 항상 fallback** 이므로, 로케일을 명시적으로 실어 `runWithLanguage`
15
15
  로 감싼다(메일은 `deliver(data, { locale })` 로 대칭 · §정본 예시).
16
16
 
17
+ `locales/ko.json`:
18
+
17
19
  ```json
18
- // locales/ko.json
19
20
  { "greeting": "안녕하세요, {{name}}님", "nav": { "home": "홈" } }
20
- // locales/en.json
21
+ ```
22
+
23
+ `locales/en.json`:
24
+
25
+ ```json
21
26
  { "greeting": "Hello, {{name}}", "nav": { "home": "Home" } }
22
27
  ```
23
28
 
29
+ 카탈로그는 **순수 JSON** 이다 — 주석·트레일링 콤마가 들어가면 로드가 깨진다.
30
+ 로케일마다 별도 파일이며 한 파일에 두 언어를 담지 않는다.
31
+
24
32
  ```ts
25
33
  import { t } from 'gaonjs/i18n'
26
34
  t('greeting', { name: '가온' }) // 요청 로케일이 ko 면 "안녕하세요, 가온님"
@@ -41,10 +49,15 @@ t('nav.home') // 중첩은 점 표기
41
49
  로 부른다 — i18next 가 `count` 와 로케일의 CLDR 규칙으로 알맞은 접미사를 고른다. 타입은
42
50
  **base 키**(`<키>`)로 검사한다(생성기가 접미사 키에서 base 키를 함께 노출 · 결정 181).
43
51
 
52
+ `locales/en.json` — 영어는 단수/복수 구분(`_one`·`_other`):
53
+
44
54
  ```json
45
- // locales/en.json — 영어는 단수/복수 구분(_one·_other)
46
55
  { "cart": { "items_one": "{{count}} item", "items_other": "{{count}} items" } }
47
- // locales/ko.json — 한국어는 복수 구분 없음(_other 만)
56
+ ```
57
+
58
+ `locales/ko.json` — 한국어는 복수 구분 없음(`_other` 만):
59
+
60
+ ```json
48
61
  { "cart": { "items_other": "상품 {{count}}개" } }
49
62
  ```
50
63