@gaonjs/cli 0.43.0 → 0.52.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 (72) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/check.d.ts +1 -1
  3. package/dist/commands/check.js +1 -1
  4. package/dist/commands/db.js +29 -7
  5. package/dist/commands/new.d.ts +2 -0
  6. package/dist/commands/new.js +8 -3
  7. package/dist/commands/test.js +16 -3
  8. package/dist/db/journal.d.ts +4 -3
  9. package/dist/db/journal.js +21 -10
  10. package/dist/db/migrate.d.ts +3 -1
  11. package/dist/db/migrate.js +3 -3
  12. package/dist/db/replay.js +2 -2
  13. package/dist/db/resolve.d.ts +15 -0
  14. package/dist/db/resolve.js +24 -2
  15. package/dist/db/status.js +13 -3
  16. package/dist/dev.d.ts +6 -4
  17. package/dist/dev.js +9 -4
  18. package/dist/doctor/fixers/index.d.ts +1 -1
  19. package/dist/doctor/fixers/index.js +6 -1
  20. package/dist/doctor/locale-parity.js +4 -1
  21. package/dist/doctor/pageprops-destructure.d.ts +2 -2
  22. package/dist/doctor/pageprops-destructure.js +29 -23
  23. package/dist/doctor/render-return.d.ts +11 -0
  24. package/dist/doctor/render-return.js +143 -0
  25. package/dist/doctor/types.d.ts +1 -1
  26. package/dist/doctor.d.ts +3 -2
  27. package/dist/doctor.js +17 -6
  28. package/dist/generate.d.ts +20 -1
  29. package/dist/generate.js +120 -21
  30. package/dist/hub.js +2 -0
  31. package/dist/i18n-config.d.ts +12 -0
  32. package/dist/i18n-config.js +95 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +59 -14
  35. package/dist/mcp/tools.d.ts +1 -1
  36. package/dist/mcp/tools.js +9 -6
  37. package/dist/messages-gen.d.ts +1 -1
  38. package/dist/messages-gen.js +5 -2
  39. package/dist/scaffold/job.js +3 -1
  40. package/dist/templates/auth/Dashboard.vue.tpl +3 -2
  41. package/dist/templates/auth/Login.vue.tpl +3 -5
  42. package/dist/templates/auth/Signup.vue.tpl +3 -5
  43. package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
  44. package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
  45. package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
  46. package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
  47. package/dist/templates/project/.dockerignore.tpl +3 -0
  48. package/dist/templates/project/.env.example.tpl +1 -1
  49. package/dist/templates/project/AGENTS.md.tpl +5 -3
  50. package/dist/templates/project/CLAUDE.md.tpl +5 -4
  51. package/dist/templates/project/Dockerfile.tpl +6 -1
  52. package/dist/templates/project/agents/async.md.tpl +75 -10
  53. package/dist/templates/project/agents/data.md.tpl +159 -22
  54. package/dist/templates/project/agents/frontend.md.tpl +51 -12
  55. package/dist/templates/project/agents/i18n.md.tpl +5 -2
  56. package/dist/templates/project/agents/mail.md.tpl +4 -2
  57. package/dist/templates/project/agents/realtime.md.tpl +37 -3
  58. package/dist/templates/project/agents/seal.md.tpl +14 -3
  59. package/dist/templates/project/agents/security.md.tpl +47 -11
  60. package/dist/templates/project/agents/storage.md.tpl +16 -8
  61. package/dist/templates/project/agents/web.md.tpl +78 -24
  62. package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
  63. package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
  64. package/dist/templates/project/apps/web/main.ts.tpl +1 -1
  65. package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
  66. package/dist/templates/project/docker-compose.yaml.tpl +1 -1
  67. package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
  68. package/dist/templates/project/vite.config.ts.tpl +1 -1
  69. package/dist/work.d.ts +21 -0
  70. package/dist/work.js +45 -1
  71. package/package.json +12 -7
  72. package/dist/templates/index.ts +0 -109
@@ -67,7 +67,9 @@ export function jobTestScaffold(pascalName) {
67
67
  ``,
68
68
  `describe('${pascal} (실 NATS JetStream)', () => {`,
69
69
  ` it('발행한 잡이 워커에서 처리된다', async () => {`,
70
- ` const nats = await connectNats(process.env.NATS_URL ?? 'nats://localhost:4222')`,
70
+ // 결정 317: 무인자 connectNats 는 옵션 객체만 받는다(문자열은 조용히 무시됐다).
71
+ // 접속지는 NATS_URL env 폴백이 정본이다(agents/testing.md 와 동일).
72
+ ` const nats = await connectNats()`,
71
73
  ` try {`,
72
74
  ` await expectJobProcessed(${camel}, () => ${camel}.later({ id: 'test' }), { nats })`,
73
75
  ` } finally {`,
@@ -8,15 +8,16 @@ import CardContent from '@shared/components/ui/CardContent.vue'
8
8
  import CardFooter from '@shared/components/ui/CardFooter.vue'
9
9
  import Button from '@shared/components/ui/Button.vue'
10
10
 
11
- // 사용자·csrf 자동 주입 공유 prop 이다(결정 116) — 컨트롤러가 넘기지 않고
11
+ // 사용자는 자동 주입 공유 prop 이다(결정 116) — 컨트롤러가 넘기지 않고
12
12
  // useShared() 로 읽는다. currentUser 는 직렬화됨(passwordDigest 없음 · §4.2).
13
13
  // 이 페이지는 requireAuth 로 보호되므로 런타임엔 항상 로그인 상태(타입은 nullable).
14
14
  const shared = useShared()
15
15
 
16
16
  // 로그아웃 = DELETE {{URL_PREFIX}}/session (r.resource('session') 의 destroy).
17
17
  // HTML <form> 은 DELETE 를 못 보내므로 Inertia 라우터로 실제 메서드를 보낸다(결정 64).
18
+ // CSRF 토큰은 프레임웍이 자동 부착한다(결정 342) — 헤더를 손으로 싣지 않는다.
18
19
  function logout(): void {
19
- router.delete('{{URL_PREFIX}}/session', { headers: { 'x-csrf-token': shared.csrf } })
20
+ router.delete('{{URL_PREFIX}}/session')
20
21
  }
21
22
  </script>
22
23
 
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { pageProps, useForm, useShared, Link } from 'gaonjs/vue'
2
+ import { pageProps, useForm, Link } from 'gaonjs/vue'
3
3
  import Card from '@shared/components/ui/Card.vue'
4
4
  import CardHeader from '@shared/components/ui/CardHeader.vue'
5
5
  import CardTitle from '@shared/components/ui/CardTitle.vue'
@@ -17,12 +17,10 @@ import AlertDescription from '@shared/components/ui/AlertDescription.vue'
17
17
  // 실패로 서버가 같은 페이지를 다시 render 하면 props.error 가 즉시 갱신된다.
18
18
  const props = pageProps<'{{APP_NAME}}:session#new'>()
19
19
 
20
- // csrf 는 자동 주입 공유 prop 이다(결정 116) — useShared() 로 읽는다.
21
- const shared = useShared()
22
-
23
20
  // 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
24
21
  // 서버는 redirect(Inertia 응답)로 답하고, 실패 시 같은 페이지를 다시 render 한다.
25
- const form = useForm({ email: '', password: '', _csrf: shared.csrf })
22
+ // CSRF 토큰은 프레임웍이 자동 부착한다(결정 342) 손으로 싣지 않는다.
23
+ const form = useForm({ email: '', password: '' })
26
24
  </script>
27
25
 
28
26
  <template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { pageProps, useForm, useShared, Link } from 'gaonjs/vue'
2
+ import { pageProps, useForm, Link } from 'gaonjs/vue'
3
3
  import Card from '@shared/components/ui/Card.vue'
4
4
  import CardHeader from '@shared/components/ui/CardHeader.vue'
5
5
  import CardTitle from '@shared/components/ui/CardTitle.vue'
@@ -15,11 +15,9 @@ import AlertDescription from '@shared/components/ui/AlertDescription.vue'
15
15
  // pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
16
16
  const props = pageProps<'{{APP_NAME}}:registration#new'>()
17
17
 
18
- // csrf 는 자동 주입 공유 prop 이다(결정 116) — useShared() 로 읽는다.
19
- const shared = useShared()
20
-
21
18
  // 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
22
- const form = useForm({ name: '', email: '', password: '', _csrf: shared.csrf })
19
+ // CSRF 토큰은 프레임웍이 자동 부착한다(결정 342) 손으로 싣지 않는다.
20
+ const form = useForm({ name: '', email: '', password: '' })
23
21
  </script>
24
22
 
25
23
  <template>
@@ -0,0 +1,18 @@
1
+ // API 앱 설정 — gaon g auth --jwt 스캐폴드. JWT(토큰) 인증을 표준 부팅(gaon dev / gaon serve)에 배선한다.
2
+ // JWT 는 API 앱 전용이다(§7) — 세션·CSRF 없이 Authorization: Bearer 로 인증한다.
3
+ import { defineAppConfig } from 'gaonjs/config'
4
+ import { loadUser } from './auth.js'
5
+
6
+ export default defineAppConfig({
7
+ // JWT secret 은 32자 이상 — .env 의 {{JWT_SECRET_ENV}} 로 주입한다(앱별 분리 · 결정 337).
8
+ // 운영(NODE_ENV=production)에서 아래 dev 폴백이 남아 있으면 부팅이 확정 종료된다(fail-loud).
9
+ auth: {
10
+ strategy: 'jwt',
11
+ secret: process.env.{{JWT_SECRET_ENV}} ?? 'dev-only-jwt-secret-{{APP_NAME}}-change-me-now!!',
12
+ loadUser,
13
+ // 액세스는 짧게, 리프레시는 길게(기본 15m / 7d). 토큰은 stateless 라 서버측
14
+ // 폐기 수단이 없다 — 민감한 앱은 refreshTtl 을 짧게 잡는다(agents/web.md §6).
15
+ accessTtl: '15m',
16
+ refreshTtl: '7d',
17
+ },
18
+ })
@@ -0,0 +1,16 @@
1
+ // 인증 배선 — gaon g auth --jwt 스캐폴드 (API 앱 · 토큰).
2
+ import type { JwtAuthOptions } from 'gaonjs/web'
3
+ import { User } from '../../domain/models/User.js'
4
+
5
+ // 액세스 토큰의 sub(사용자 id)로 사용자를 로드한다(§7 · JWT 는 API 앱 전용).
6
+ export const loadUser: JwtAuthOptions['loadUser'] = async (id) =>
7
+ await User.where('id', '=', BigInt(String(id))).first()
8
+
9
+ // this.currentUser 에 User 필드 타입을 얹는다(GaonRouteMap 과 동일 관례).
10
+ declare module 'gaonjs/web' {
11
+ interface GaonCurrentUser {
12
+ id: bigint
13
+ name: string
14
+ email: string
15
+ }
16
+ }
@@ -0,0 +1,7 @@
1
+ import { routes } from 'gaonjs/web'
2
+
3
+ export default routes((r) => {
4
+ r.post('/session', 'session#create') // 로그인 → 토큰 발급 (gaon g auth --jwt)
5
+ r.post('/session/refresh', 'session#refresh') // 액세스 토큰 재발급 (gaon g auth --jwt)
6
+ r.get('/session', 'session#show') // 현재 사용자 · Bearer (gaon g auth --jwt)
7
+ })
@@ -0,0 +1,36 @@
1
+ // 토큰 컨트롤러(발급/재발급/내 정보) — gaon g auth --jwt 스캐폴드 (API 앱 · JSON 전용).
2
+ // API 앱은 프론트엔드가 없다 — 모든 응답이 JSON 이고 페이지·useForm 을 쓰지 않는다.
3
+ import { controller, verifyPassword } from 'gaonjs/web'
4
+ import { User } from '../../../domain/models/User.js'
5
+
6
+ export default controller({
7
+ // POST {{URL_PREFIX}}/session — 로그인: 자격 검증 후 액세스+리프레시 토큰 발급.
8
+ // curl -X POST -H 'Content-Type: application/json' \
9
+ // -d '{"email":"a@x.com","password":"..."}' http://127.0.0.1:3000{{URL_PREFIX}}/session
10
+ async create() {
11
+ const { email, password } = this.params({ _row: {} as { email: string; password: string } })
12
+ const user = await User.where('email', '=', email).first()
13
+ if (!user || !(await verifyPassword(password, user.passwordDigest))) {
14
+ return this.json({ error: 'invalid_credentials', message: '이메일 또는 비밀번호가 올바르지 않습니다.' }, 401)
15
+ }
16
+ // this.jwt 는 JWT 앱에서만 존재한다(app.config 의 auth.strategy:'jwt' 배선).
17
+ const tokens = await this.jwt!.issue(user)
18
+ return this.json(tokens) // { accessToken, refreshToken }
19
+ },
20
+ // POST {{URL_PREFIX}}/session/refresh — 리프레시 토큰으로 액세스 토큰 재발급.
21
+ // 주의: 토큰은 stateless — 서버측 폐기(로그아웃·강제 무효화) 수단이 없다(결정 337).
22
+ // 유출된 리프레시 토큰은 만료까지 유효하므로 민감한 앱은 refreshTtl 을 짧게 잡는다.
23
+ async refresh() {
24
+ const { refreshToken } = this.params({ _row: {} as { refreshToken: string } })
25
+ const next = await this.jwt!.refresh(refreshToken)
26
+ if (!next) {
27
+ return this.json({ error: 'invalid_refresh_token', message: '리프레시 토큰이 유효하지 않거나 만료됐습니다.' }, 401)
28
+ }
29
+ return this.json(next) // { accessToken }
30
+ },
31
+ // GET {{URL_PREFIX}}/session — 현재 사용자 확인 (Authorization: Bearer <accessToken>).
32
+ async show() {
33
+ const user = this.requireAuth() // 토큰이 없거나 무효면 401
34
+ return this.json({ user }) // hidden 컬럼(passwordDigest)은 응답 경계에서 제외된다(§4.2)
35
+ },
36
+ })
@@ -8,5 +8,8 @@ dist
8
8
  .git
9
9
  .env
10
10
  .env.*
11
+ # .env.example 은 예외로 포함한다 — gaon build 는 .gaon/env.d.ts 생성에 .env 가
12
+ # 필수(결정 198)라, 빌드 스테이지가 이를 복제해 임시 .env 를 만든다(결정 313).
13
+ !.env.example
11
14
  npm-debug.log*
12
15
  *.log
@@ -1,5 +1,5 @@
1
1
  # {{PROJECT_NAME}} 환경 변수 — gaon new 가 .env 로 자동 복제(결정 198). 값만 채워 쓰세요.
2
- # gaon.config.ts env('KEY')참조된다. gaon dev · gaon serve 가 자동 로드.
2
+ # gaon.config.ts process.env.KEY 로 참조한다. gaon dev · gaon serve 가 자동 로드.
3
3
 
4
4
  # DB — docker-compose.yaml 의 postgres 서비스와 정합.
5
5
  DATABASE_URL=postgres://{{PROJECT_NAME}}:{{PROJECT_NAME}}@127.0.0.1:5432/{{PROJECT_NAME}}_dev
@@ -111,7 +111,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
111
111
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
112
112
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
113
113
 
114
- ### 2.2 `gaon doctor` 검사 27
114
+ ### 2.2 `gaon doctor` 검사 28
115
115
 
116
116
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
117
117
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -132,7 +132,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
132
132
  17. `method-override` — `_method` HTTP 메서드 스푸핑 hack(Gaon 미지원 · router.delete 를 쓰라) (결정 89 · 경고)
133
133
  18. `csrf-wiring` — 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 `app.config.ts` 에 session 미배선 = CSRF 무방비 (결정 93 · 경고)
134
134
  19. `internal-anchor` — 앱 내부 경로 일반 `<a href="/...">`(풀 리로드로 SPA 파손 · `Link`/`router.visit` 를 쓰라 · 외부 URL·`target="_blank"` 는 제외) (결정 96 · 경고)
135
- 20. `pageprops-destructure` — `const { x } = pageProps(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라) (결정 99 · 경고)
135
+ 20. `pageprops-destructure` — `const { x } = pageProps(…)`/`useShared(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라 · `.vue`+앱 `.ts` 공통) (결정 99 · 302 · 경고)
136
136
  21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
137
137
  22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
138
138
  23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
@@ -140,6 +140,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
140
140
  25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). **파티션 키 컬럼이 실제 컬럼인지도 검사**(결정 277 · `checkPartitions` — 오타·유령 컬럼). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`·`checkPartitions`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134·277 · `agents/data.md`)
141
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)
142
142
  27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
143
+ 28. `render-return` — 액션이 `this.render`/`this.redirect`/`this.json` 을 호출만 하고 `return` 하지 않음 = 응답이 버려져 조용히 204(백지) — `return this.render(...)` 로 고치라 (결정 340 · 경고)
143
144
 
144
145
  ## 3. 로직 배치 One Way 판단표
145
146
 
@@ -200,7 +201,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
200
201
  ```bash
201
202
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
202
203
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
203
- gaon doctor # 정적 검사 27종 (§2.2)
204
+ gaon doctor # 정적 검사 28종 (§2.2)
204
205
  ```
205
206
 
206
207
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -252,6 +253,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
252
253
  | `@gaonjs/core` | 메타데이터 · 직렬화 프리미티브(Hidden) |
253
254
  | `@gaonjs/mail` / `storage` / `i18n` | 메일 · 파일 스토리지 · 다국어 |
254
255
  | `@gaonjs/seal` | 페이로드 봉인(선택 플러그인 · `agents/seal.md`) |
256
+ | `@gaonjs/adapter-mongo` | MongoDB 문서형 `collection()` 어댑터(선택 · mongoose optional peer · `agents/data.md`) |
255
257
 
256
258
  ## 6. 원칙 · 엄수
257
259
 
@@ -9,7 +9,7 @@
9
9
 
10
10
  Gaon 프레임웍 문서: https://gaonjs.dev
11
11
 
12
- - 설계 정본(v0.15) · errata E-1(파사드 = `gaonjs`) · E-3(JSON 액션 +
12
+ - 설계 정본(v0.17) · errata E-1(파사드 = `gaonjs`) · E-3(JSON 액션 +
13
13
  `api()`) · E-4(컬럼 확장) · E-5(컴포저블·레이아웃)
14
14
  - 앱 내 One Way(§1) — 선택지가 있는 것을 만들지 않는다. 하나로 정한다.
15
15
 
@@ -17,8 +17,9 @@ Gaon 프레임웍 문서: https://gaonjs.dev
17
17
 
18
18
  1. **TypeScript 전용.** JS 파일 추가 금지. 데코레이터 금지 — 함수·객체
19
19
  스타일(`model()`·`controller()`·`job()`) 만 쓴다.
20
- 2. **`.gaon/` 자동 생성 파일 편집 금지.** `routes.d.ts`·`tables.d.ts`
21
- 는 `gaon check` / `gaon dev` 가 재생성한다.
20
+ 2. **`.gaon/` 자동 생성 파일 편집 금지.** `routes.d.ts`·`routes.manifest.ts
21
+ `tables.d.ts`·`messages.d.ts`·`env.d.ts` 는 `gaon check` / `gaon dev` /
22
+ `gaon gen` 이 재생성한다.
22
23
  3. **의존 방향 4규칙**(doctor 강제): 앱→domain 허용 · domain→앱 금지 ·
23
24
  앱→앱 금지 · 앱→shared 허용(shared 는 앱 import 금지, domain 은
24
25
  타입 import 만).
@@ -85,7 +86,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
85
86
 
86
87
  ```bash
87
88
  gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
88
- gaon doctor # 정적 검사 27종 (상세 AGENTS §2.2)
89
+ gaon doctor # 정적 검사 28종 (상세 AGENTS §2.2)
89
90
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
90
91
  ```
91
92
 
@@ -18,7 +18,12 @@ RUN pnpm install --frozen-lockfile
18
18
  FROM base AS build
19
19
  COPY --from=deps /app/node_modules ./node_modules
20
20
  COPY . .
21
- RUN pnpm build
21
+ # gaon build 는 .gaon/env.d.ts 생성에 .env 가 필수(결정 198)인데 .env 는 이미지에
22
+ # 넣지 않는다(.dockerignore) — .env.example 을 임시 복제해 빌드하고 즉시 지운다.
23
+ # 지우는 이유: example 의 placeholder(SESSION_SECRET 등)가 런타임에 남으면 compose
24
+ # 가 env 를 안 넘겼을 때 fail-loud 검증을 조용히 통과시킨다(결정 313). 런타임 env
25
+ # 는 compose.prod.yaml 의 environment 가 단일 소스다.
26
+ RUN cp .env.example .env && pnpm build && rm -f .env
22
27
 
23
28
  # 3) 런타임 — 소스 + 번들 + 의존을 그대로 실행.
24
29
  FROM base AS runtime
@@ -92,7 +92,17 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
92
92
  - **옵션** — `queue`(기본 `'default'`) · `retries`(기본 3) ·
93
93
  `curve`(백오프 곡선 ms) · `jitter` · `concurrency`.
94
94
  - **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
95
- `gaon jobs retry <id>` 로 조회·재적재한다.
95
+ `gaon jobs retry <id>` 로 조회·재적재한다(조회는 전량 배치 스캔 — 옛 레코드도
96
+ 상한 없이 찾아 재적재할 수 있다 · 결정 351).
97
+ - **네이티브 재전달 소진도 DLQ 로 간다(결정 347).** 크래시 루프·미등록 잡(워커에
98
+ `domain/jobs/` 파일이 배포되지 않음)이 재전달 상한(기본 25 · `maxDeliver`)을
99
+ 소진하면, 워커가 MAX_DELIVERIES advisory 를 받아 그 잡을 DLQ 로 이관한다 —
100
+ 이전엔 스트림에 무신호로 영구 잔류했다. 미등록 잡의 재전달 지연은 지수
101
+ (1s→2s→…30s 포화)이고 잡 이름당 1회 경고를 남긴다(정상 롤링 배포 창은 통과).
102
+ advisory 는 비영속이라 백스톱은 best-effort 다(소진 순간 워커가 전무하면 다음
103
+ 소진 때 회수).
104
+ - **큐 동시성은 큐별로 정확히 적용된다(결정 348)** — 다른 큐의 긴 잡이 이 큐의
105
+ 처리량을 깎지 않는다(잡별 `concurrency` 선언 = 그 큐의 실제 동시 처리 수).
96
106
  - **워커 복원력(결정 258)** — 재시도 재적재나 DLQ 이관을 하는 도중 NATS 가
97
107
  순단해 발행 자체가 실패해도, 워커의 큐 소비 루프는 **멈추지 않는다**. 그 잡은
98
108
  ack/DLQ 하지 않고 되돌려(재전달 백스톱) 유실을 막고, 실패는 로그로 남긴다 —
@@ -175,6 +185,18 @@ await OrderPlaced.emit({ orderId: 1n })
175
185
 
176
186
  여러 리스너가 같은 이벤트를 durable 컨슈머로 구독하며, 각자 재시도된다.
177
187
 
188
+ **리스너 재시도·폐기 계약 (결정 308 명문화).** 리스너 실패는 잡보다 단순하게
189
+ 다룬다 — **DLQ 가 없다**:
190
+
191
+ - 실패하면 백오프(잡과 같은 곡선 `[1s, 5s, 30s, 5m, 1h]`)로 재전달되고, 최대
192
+ 재전달(기본 6 · 최초 포함) 소진 시 **영구 폐기**된다. 즉 계속 실패하는 이벤트는
193
+ 약 **1시간 36분** 뒤 사라진다 — `gaon work` 가 `✗ 이벤트 폐기` 로 신호한다
194
+ (결정 308 · 이전엔 human 모드 무신호). 놓치면 안 되는 처리는 리스너에서 잡을
195
+ 발행해(`.later()`) 잡의 재시도·DLQ 배터리로 넘긴다.
196
+ - **리스너별 순차 처리는 보장되지 않는다** — 재시도 대기 중 다음 이벤트가 먼저
197
+ 처리될 수 있고, 드물게 같은 리스너의 두 이벤트가 겹칠 수 있다. 순서·중복에
198
+ 기대지 말고 **멱등**하게 짠다(at-least-once 계약과 동일 축).
199
+
178
200
  ### 4. 아웃박스 (트랜잭션 정합)
179
201
 
180
202
  이벤트를 DB 트랜잭션과 **원자적으로** 발행하려면 아웃박스를 쓴다.
@@ -204,17 +226,31 @@ export const PlaceOrder = service(async (input: { name: string }) => {
204
226
  - 대부분의 도메인 코드는 `service()` 본문에서 emit 하거나, "커밋 후 즉시
205
227
  발행"이면 `afterCommit(fn)`(§서비스)을 쓴다. 저수준 원시 `runInTransaction(db, fn)`
206
228
  (커넥션을 직접 넘긴다)은 프레임웍 밖에서 트랜잭션을 손수 열 때만 쓰는 탈출구다.
207
- - 릴레이(`gaon work` 내장)가 `SKIP LOCKED` 아웃박스를 폴링해 발행
208
- 한다 (기본 1000ms · 배치 100).
229
+ - 릴레이(`gaon work` 내장)가 아웃박스를 폴링해 발행한다(기본 1000ms ·
230
+ 배치 100). **2단계 publish(결정 346)**: 짧은 트랜잭션에서 행을 선점(`SKIP
231
+ LOCKED` + `claimed_at`)하고 커밋해 락을 즉시 놓은 뒤, 트랜잭션 **밖**에서
232
+ NATS 로 발행하고 성공 행만 `published_at` 을 찍는다 — NATS 지연·순단이 DB
233
+ 행 락/커넥션 점유로 전파되지 않는다. 선점 리스(claim · 기본 60s ·
234
+ `claimTimeoutMs`)가 지나면(크래시·발행 실패) 재클레임된다 — 미발행 행은
235
+ 어떤 경로로도 삭제되지 않으므로 유실이 없다.
209
236
  - at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
210
- 흡수한다.
237
+ 흡수한다(claim 리스 60s < dedupe 창 120s 라 "발행 후 표시 전 크래시"
238
+ 재발행도 창 안에서 접힌다).
211
239
  - 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
212
240
  기동 시 보장된다(결정 144 · nats 설정이 있을 때).
213
241
  - 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
214
- (결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `runWork()`
215
- 프로그래매틱 옵션 `outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(`gaon
216
- work` CLI 플래그가 아니다 · 운영 상세는 `docs/guides/operations.md`). 미발행 행은
217
- 절대 삭제되지 않는다.
242
+ (결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격·폴링 주기는 env
243
+ `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`
244
+ 조정한다(결정 312 · `GAON_WORKER_*` 대칭 · `gaon work` 가 읽는다). 프로그래매틱
245
+ 경로는 `runWork()` 의 `outboxRetentionMs`·`outboxPurgeIntervalMs`·`relayPollMs`.
246
+ 미발행 행은 절대 삭제되지 않는다.
247
+ - **발행 실패는 행 단위로 격리된다(결정 306 · 346).** 한 행의 publish 가 실패해도
248
+ (예: 페이로드가 NATS `max_payload` 1MiB 초과) 그 행만 claim 된 채 남아 리스
249
+ 만료(60s) 후 재시도되고, 뒤 행들은 정상 발행된다 — 한 행이 아웃박스 전체를
250
+ 조용히 세우지 않고, 실패 행 재시도도 폴링(1s) 해머링이 아니라 60s 로 자연
251
+ 스로틀된다. 실패는 `gaon work` 에 `⚠ 아웃박스 릴레이 오류` 로 신호된다.
252
+ 이벤트 페이로드는 1MiB 미만으로 유지한다 — 큰 데이터는 본문 대신 id 를 실어
253
+ 리스너가 조회하게 한다.
218
254
 
219
255
  ### 5. 스케줄러
220
256
 
@@ -242,6 +278,18 @@ export default schedule((s) => {
242
278
  | `s.daily.at('HH:MM', Job)` | 매일 지정 시각 |
243
279
  | `s.cron('분 시 일 월 요일', Job)` | 5필드 크론 표현식 |
244
280
 
281
+ - **스케줄 대상 잡은 무인자여야 한다(결정 310).** 스케줄러는 인자 없이 발화하므로
282
+ 인자 필수 잡(`job(async (userId: bigint) => ...)`)을 등록하면 **컴파일 에러**로
283
+ 거부된다(이전엔 통과해 `undefined` 인자로 도는 무신호 버그였다). 인자가 필요하면
284
+ 인자를 안에서 결정하는 무인자 잡으로 감싼다(선택 인자 `tag?: string` 는 허용).
285
+ - **크론 부분집합 주의** — 스텝(`/n`)은 범위(`*`·`a-b`)에만 의미가 있다:
286
+ `*/2`·`1-9/2` 는 스텝이고, `5/2` 는 `{5}` 단일 값으로 해석된다(Vixie 계열의
287
+ `5/2`=5부터 스텝 확장은 지원하지 않음). 초 단위·`?`·`L`·`#` 도 범위 밖(§5 서두).
288
+ - **`s.every` 는 프로세스 정지를 따라잡는다** — 절전·일시정지 후 재개하면 밀린
289
+ 주기만큼 연속 발화(캐치업 버스트)할 수 있다. 틱당 정확히 1회가 필요하면
290
+ `s.cron`(분 경계 1회)을 쓰거나 잡을 멱등 + `lock(key, fn, { onBusy: 'skip' })`
291
+ 으로 지킨다(§중첩 방지).
292
+
245
293
  #### 리더 선출 계약 (정본)
246
294
 
247
295
  여러 `gaon work` 인스턴스를 HA 로 띄워도 스케줄이 중복 발행되지 않는 이유:
@@ -256,6 +304,11 @@ export default schedule((s) => {
256
304
  dedupe 키 + JetStream 중복 윈도우가 이를 1회로 수렴시킨다(결정 233).
257
305
  잡 자체는 재시도(백오프)가 있으니 **핸들러는 멱등**하게 짠다(같은 잡이 두 번
258
306
  처리돼도 안전하게).
307
+ - **`s.every` 위상은 리더 교체를 가로질러 보존된다(결정 349).** 마지막 발화
308
+ 시각이 KV(`gaon_scheduler`)에 남아, 새 리더는 밀렸으면 즉시 1회 발화하고
309
+ 아니면 잔여 시간만 기다린다 — 재선출마다 타이머가 리셋돼 리스 플래핑이
310
+ interval 보다 잦으면 every 잡이 영영 안 돌던 기아가 없다. 리스 갱신도 순단
311
+ 1~2회는 재시도 후에만 리더를 내려놓는다(불필요한 failover·리셋 억제).
259
312
  - **`gaon serve` 는 스케줄러를 돌리지 않는다** — 스케줄·리더 선출·아웃박스
260
313
  릴레이는 **`gaon work` 전용**이다. 웹 프로세스는 잡을 **발행**만 할 수 있고
261
314
  (`.later()`), 처리·스케줄은 워커가 한다. **운영에서** 스케줄이 안 도는 흔한
@@ -401,8 +454,11 @@ async create() {
401
454
  소속 · 어느 앱에서든 큐잉).
402
455
  - **`export const <Pascal> = job(...)`** — export 없이 정의만 하면
403
456
  컨트롤러가 import 해 `.later()` 를 부를 수 없다.
404
- - **스케줄 대상은 항상 잡** — `s.every('10m', async () => ...)` 인라인
405
- 함수 금지.
457
+ - **스케줄 대상은 항상 · 무인자** — `s.every('10m', async () => ...)` 인라인
458
+ 함수 금지. 인자 필수 잡은 컴파일 에러로 거부된다(결정 310 · §5).
459
+ - **계속 실패하는 리스너는 이벤트를 잃는다** — 리스너는 DLQ 가 없어 재전달
460
+ 소진(기본 6회 · 약 1h36m) 후 영구 폐기된다(§3 계약 · `gaon work` 가 `✗ 이벤트
461
+ 폐기` 로 신호). 유실 불가 처리는 리스너에서 잡으로 넘긴다.
406
462
  - **커밋 전 발행 주의** — 트랜잭션 안에서 DB 확정 후에만 나가야 하는
407
463
  발행은 `afterCommit()` 또는 아웃박스로.
408
464
  - **테스트에서 NATS 목업 금지** (§9) — 실 JetStream 에 접속한다
@@ -428,4 +484,13 @@ async create() {
428
484
  | 결정 233 | 크론 리더 페일오버 중복 발행 dedupe (결정론적 dedupe 키 + JetStream 중복 윈도우로 틱당 1회 발행 수렴 · §5) |
429
485
  | 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
430
486
  | 결정 258 | 워커 소비 루프 복원력(§1) — 재시도/DLQ 발행이 NATS 순단으로 실패해도 큐 소비가 멈추지 않음(nak 재전달 백스톱 · 잡 유실 방지 · 실패 로그) |
487
+ | 결정 306 | 아웃박스 발행 실패 행 격리(§4) — poison 행(페이로드 상한 초과 등)이 배치 전체를 세우지 않음 · 실패 행은 미발행 유지(유실 없음·재시도) · `relay-error` 이벤트로 관측 |
488
+ | 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ 없음·~1h36m) 명문화 |
489
+ | 결정 310 | 스케줄 대상 잡 무인자 가드(§5) — 인자 필수 잡 등록을 컴파일 타임 거부(메서드 bivariance 로 통과해 `undefined` 인자 발화하던 구멍 차단) |
490
+ | 결정 312 | 아웃박스 릴레이 env 튜닝(§4) — `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`(`GAON_WORKER_*` 대칭) |
491
+ | 결정 346 | 아웃박스 2단계 publish(§4) — claim(`claimed_at`+SKIP LOCKED 짧은 tx) → 커밋 → tx 밖 publish → 표시 · NATS 지연의 DB 락 전파 제거 · claim 리스 60s(< dedupe 창) · 유실 0 |
492
+ | 결정 347 | max_deliver 소진 DLQ 백스톱(§1) — MAX_DELIVERIES advisory → DLQ 이관(무신호 영구 잔류 봉합) · 미등록 잡 지수 nak(1s→30s 포화)+이름당 1회 경고 · `maxDeliver` 옵션 |
493
+ | 결정 348 | 워커 큐별 동시성 게이트(§1) — 전역 inflight 비교가 낳던 교차 큐 간섭 제거(선언 `concurrency` = 실제 동시 처리) |
494
+ | 결정 349 | 리스 갱신 순단 재시도 + every 위상 KV 보존(§5) — 키가 내 것이면 revision 동기화 재시도 후에만 revoke · `gaon_scheduler` KV 로 위상 이어받기(플래핑 기아 봉합) |
495
+ | 결정 351 | DLQ 조회 배치 스캔(§1) — ordered 컨슈머 fetch 로 삭제 갭 서버 스킵 · findDlq 1000건 상한 제거(옛 레코드 retry 복원) |
431
496
  | §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |