@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.
- package/README.md +1 -1
- package/dist/commands/check.d.ts +1 -1
- package/dist/commands/check.js +1 -1
- package/dist/commands/db.js +29 -7
- package/dist/commands/new.d.ts +2 -0
- package/dist/commands/new.js +8 -3
- package/dist/commands/test.js +16 -3
- package/dist/db/journal.d.ts +4 -3
- package/dist/db/journal.js +21 -10
- package/dist/db/migrate.d.ts +3 -1
- package/dist/db/migrate.js +3 -3
- package/dist/db/replay.js +2 -2
- package/dist/db/resolve.d.ts +15 -0
- package/dist/db/resolve.js +24 -2
- package/dist/db/status.js +13 -3
- package/dist/dev.d.ts +6 -4
- package/dist/dev.js +9 -4
- package/dist/doctor/fixers/index.d.ts +1 -1
- package/dist/doctor/fixers/index.js +6 -1
- package/dist/doctor/locale-parity.js +4 -1
- package/dist/doctor/pageprops-destructure.d.ts +2 -2
- package/dist/doctor/pageprops-destructure.js +29 -23
- package/dist/doctor/render-return.d.ts +11 -0
- package/dist/doctor/render-return.js +143 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +3 -2
- package/dist/doctor.js +17 -6
- package/dist/generate.d.ts +20 -1
- package/dist/generate.js +120 -21
- package/dist/hub.js +2 -0
- package/dist/i18n-config.d.ts +12 -0
- package/dist/i18n-config.js +95 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +59 -14
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +9 -6
- package/dist/messages-gen.d.ts +1 -1
- package/dist/messages-gen.js +5 -2
- package/dist/scaffold/job.js +3 -1
- package/dist/templates/auth/Dashboard.vue.tpl +3 -2
- package/dist/templates/auth/Login.vue.tpl +3 -5
- package/dist/templates/auth/Signup.vue.tpl +3 -5
- package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
- package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
- package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
- package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
- package/dist/templates/project/.dockerignore.tpl +3 -0
- package/dist/templates/project/.env.example.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +5 -3
- package/dist/templates/project/CLAUDE.md.tpl +5 -4
- package/dist/templates/project/Dockerfile.tpl +6 -1
- package/dist/templates/project/agents/async.md.tpl +75 -10
- package/dist/templates/project/agents/data.md.tpl +159 -22
- package/dist/templates/project/agents/frontend.md.tpl +51 -12
- package/dist/templates/project/agents/i18n.md.tpl +5 -2
- package/dist/templates/project/agents/mail.md.tpl +4 -2
- package/dist/templates/project/agents/realtime.md.tpl +37 -3
- package/dist/templates/project/agents/seal.md.tpl +14 -3
- package/dist/templates/project/agents/security.md.tpl +47 -11
- package/dist/templates/project/agents/storage.md.tpl +16 -8
- package/dist/templates/project/agents/web.md.tpl +78 -24
- package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
- package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
- package/dist/templates/project/apps/web/main.ts.tpl +1 -1
- package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
- package/dist/templates/project/docker-compose.yaml.tpl +1 -1
- package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
- package/dist/templates/project/vite.config.ts.tpl +1 -1
- package/dist/work.d.ts +21 -0
- package/dist/work.js +45 -1
- package/package.json +12 -7
- package/dist/templates/index.ts +0 -109
package/dist/scaffold/job.js
CHANGED
|
@@ -67,7 +67,9 @@ export function jobTestScaffold(pascalName) {
|
|
|
67
67
|
``,
|
|
68
68
|
`describe('${pascal} (실 NATS JetStream)', () => {`,
|
|
69
69
|
` it('발행한 잡이 워커에서 처리된다', async () => {`,
|
|
70
|
-
|
|
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
|
-
//
|
|
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'
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# {{PROJECT_NAME}} 환경 변수 — gaon new 가 .env 로 자동 복제(결정 198). 값만 채워 쓰세요.
|
|
2
|
-
# gaon.config.ts
|
|
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` 검사
|
|
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 # 정적 검사
|
|
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.
|
|
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`·`
|
|
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 # 정적 검사
|
|
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
|
-
|
|
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` 내장)가
|
|
208
|
-
|
|
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 코드를 쓰지 말 것. 보존
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
- **스케줄 대상은 항상
|
|
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 벤치마크 확정) |
|