@gaonjs/cli 0.47.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 (49) hide show
  1. package/dist/commands/check.d.ts +1 -1
  2. package/dist/commands/check.js +1 -1
  3. package/dist/commands/test.js +16 -3
  4. package/dist/db/journal.d.ts +4 -3
  5. package/dist/db/journal.js +21 -10
  6. package/dist/db/migrate.d.ts +3 -1
  7. package/dist/db/migrate.js +3 -3
  8. package/dist/db/replay.js +2 -2
  9. package/dist/db/status.js +13 -3
  10. package/dist/dev.d.ts +6 -4
  11. package/dist/dev.js +9 -4
  12. package/dist/doctor/fixers/index.d.ts +1 -1
  13. package/dist/doctor/fixers/index.js +6 -1
  14. package/dist/doctor/locale-parity.js +4 -1
  15. package/dist/doctor/render-return.d.ts +11 -0
  16. package/dist/doctor/render-return.js +143 -0
  17. package/dist/doctor/types.d.ts +1 -1
  18. package/dist/doctor.d.ts +3 -2
  19. package/dist/doctor.js +16 -5
  20. package/dist/generate.d.ts +20 -1
  21. package/dist/generate.js +120 -21
  22. package/dist/hub.js +2 -0
  23. package/dist/i18n-config.d.ts +12 -0
  24. package/dist/i18n-config.js +95 -0
  25. package/dist/index.js +41 -12
  26. package/dist/mcp/tools.d.ts +1 -1
  27. package/dist/mcp/tools.js +9 -6
  28. package/dist/messages-gen.d.ts +1 -1
  29. package/dist/messages-gen.js +5 -2
  30. package/dist/templates/auth/Dashboard.vue.tpl +3 -2
  31. package/dist/templates/auth/Login.vue.tpl +3 -5
  32. package/dist/templates/auth/Signup.vue.tpl +3 -5
  33. package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
  34. package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
  35. package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
  36. package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
  37. package/dist/templates/project/AGENTS.md.tpl +3 -2
  38. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  39. package/dist/templates/project/agents/async.md.tpl +35 -8
  40. package/dist/templates/project/agents/data.md.tpl +113 -49
  41. package/dist/templates/project/agents/frontend.md.tpl +15 -4
  42. package/dist/templates/project/agents/i18n.md.tpl +5 -2
  43. package/dist/templates/project/agents/mail.md.tpl +2 -1
  44. package/dist/templates/project/agents/realtime.md.tpl +16 -6
  45. package/dist/templates/project/agents/seal.md.tpl +6 -3
  46. package/dist/templates/project/agents/security.md.tpl +37 -19
  47. package/dist/templates/project/agents/storage.md.tpl +6 -5
  48. package/dist/templates/project/agents/web.md.tpl +40 -22
  49. package/package.json +6 -6
package/dist/mcp/tools.js CHANGED
@@ -434,17 +434,20 @@ function parseJsonTail(output) {
434
434
  * AI 에이전트가 자기 산출물을 스스로 확증하는 표준 경로.
435
435
  *
436
436
  * 인자:
437
- * { only?: 'typecheck'|'vue-tsc'|'build'|'doctor', includeDoctor?: boolean }
437
+ * { only?: 'typecheck'|'vue-tsc'|'build'|'doctor', noDoctor?: boolean }
438
438
  */
439
439
  export async function runCheckTool(args, cwd) {
440
440
  const only = stringOpt(args, 'only');
441
- const includeDoctor = boolOpt(args, 'includeDoctor');
441
+ // 결정 362: doctor 는 check 에 **기본 포함**(결정 157). 구 파라미터 includeDoctor
442
+ // no-op CLI 플래그(--include-doctor)로 흘러 "false 여도 doctor 가 도는" 유령이었다 —
443
+ // 실 토글은 noDoctor(--no-doctor)다. includeDoctor 는 하위 호환으로 받되 무시한다.
444
+ const noDoctor = boolOpt(args, 'noDoctor');
442
445
  const known = ['typecheck', 'vue-tsc', 'build', 'doctor'];
443
446
  if (only && !known.includes(only)) {
444
447
  return errorResult(`only 는 ${known.map((k) => `'${k}'`).join(' | ')} 중 하나여야 합니다. 입력값: '${only}'\n` +
445
448
  `→ 인자를 고치거나 생략해 전체 검사를 실행하세요.`);
446
449
  }
447
- const cliArgs = ['check', '--json', ...(only ? ['--only', only] : []), ...(includeDoctor ? ['--include-doctor'] : [])];
450
+ const cliArgs = ['check', '--json', ...(only ? ['--only', only] : []), ...(noDoctor ? ['--no-doctor'] : [])];
448
451
  const r = await spawnGaon(cwd, cliArgs);
449
452
  if ('error' in r)
450
453
  return errorResult(r.error);
@@ -454,7 +457,7 @@ export async function runCheckTool(args, cwd) {
454
457
  ok: r.exitCode === 0,
455
458
  exitCode: r.exitCode,
456
459
  only: only ?? null,
457
- includeDoctor,
460
+ noDoctor: noDoctor ?? false,
458
461
  report: parseJsonTail(r.output) ?? null,
459
462
  output: r.output,
460
463
  },
@@ -590,7 +593,7 @@ export const TOOLS = [
590
593
  name: 'run_check',
591
594
  description: '`gaon check` 를 실 실행한다 — 검사 전에 .gaon 타입 브리지를 재생성(규칙 3)한 뒤 ' +
592
595
  'typecheck·vue-tsc·build 를 순서대로 돌린다. 산출물을 고친 뒤 스스로 확증하는 표준 경로 ' +
593
- '(CLAUDE.md §3 검증 루프). only 로 한 단계만, includeDoctor 로 doctor 까지 포함.',
596
+ '(CLAUDE.md §3 검증 루프). doctor 는 기본 포함(결정 157) — only 로 한 단계만, noDoctor 로 doctor 제외.',
594
597
  inputSchema: {
595
598
  type: 'object',
596
599
  properties: {
@@ -599,7 +602,7 @@ export const TOOLS = [
599
602
  enum: ['typecheck', 'vue-tsc', 'build', 'doctor'],
600
603
  description: '지정 시 그 검사 하나만 실행. 생략 시 전체.',
601
604
  },
602
- includeDoctor: { type: 'boolean', description: 'true 면 doctor 함께 실행. 기본 false.' },
605
+ noDoctor: { type: 'boolean', description: 'true 면 doctor 제외(기본 false — doctor 는 기본 포함 · 결정 157).' },
603
606
  },
604
607
  additionalProperties: false,
605
608
  },
@@ -3,4 +3,4 @@
3
3
  * 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
4
4
  * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
5
5
  */
6
- export declare function generateMessagesDts(localesDir: string, out: string): boolean;
6
+ export declare function generateMessagesDts(localesDir: string, out: string, baseLng?: string): boolean;
@@ -12,13 +12,16 @@ import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
12
12
  * 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
13
13
  * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
14
14
  */
15
- export function generateMessagesDts(localesDir, out) {
15
+ export function generateMessagesDts(localesDir, out, baseLng) {
16
16
  if (!existsSync(localesDir))
17
17
  return false;
18
18
  const resources = loadLocales(localesDir);
19
19
  if (Object.keys(resources).length === 0)
20
20
  return false;
21
21
  mkdirSync(dirname(out), { recursive: true });
22
- writeFileSync(out, renderMessagesDts(resources), 'utf8');
22
+ // 결정 352: 기준 로케일 = config i18n.fallbackLng(호출자가 정적 분석으로 전달).
23
+ // 이전엔 항상 알파벳순 첫 로케일이라 컴파일 보증이 fallback 체인과 다른 로케일에
24
+ // 정박했다(en/ko + fallbackLng:'ko' 프로젝트에서 기준이 en).
25
+ writeFileSync(out, renderMessagesDts(resources, baseLng), 'utf8');
23
26
  return true;
24
27
  }
@@ -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
+ })
@@ -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)
@@ -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` 지원)
@@ -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 # 정적 검사 27종 (상세 AGENTS §2.2)
89
+ gaon doctor # 정적 검사 28종 (상세 AGENTS §2.2)
90
90
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
91
91
  ```
92
92
 
@@ -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 하지 않고 되돌려(재전달 백스톱) 유실을 막고, 실패는 로그로 남긴다 —
@@ -216,10 +226,16 @@ export const PlaceOrder = service(async (input: { name: string }) => {
216
226
  - 대부분의 도메인 코드는 `service()` 본문에서 emit 하거나, "커밋 후 즉시
217
227
  발행"이면 `afterCommit(fn)`(§서비스)을 쓴다. 저수준 원시 `runInTransaction(db, fn)`
218
228
  (커넥션을 직접 넘긴다)은 프레임웍 밖에서 트랜잭션을 손수 열 때만 쓰는 탈출구다.
219
- - 릴레이(`gaon work` 내장)가 `SKIP LOCKED` 아웃박스를 폴링해 발행
220
- 한다 (기본 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
+ 어떤 경로로도 삭제되지 않으므로 유실이 없다.
221
236
  - at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
222
- 흡수한다.
237
+ 흡수한다(claim 리스 60s < dedupe 창 120s 라 "발행 후 표시 전 크래시"
238
+ 재발행도 창 안에서 접힌다).
223
239
  - 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
224
240
  기동 시 보장된다(결정 144 · nats 설정이 있을 때).
225
241
  - 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
@@ -228,10 +244,11 @@ export const PlaceOrder = service(async (input: { name: string }) => {
228
244
  로 조정한다(결정 312 · `GAON_WORKER_*` 와 대칭 · `gaon work` 가 읽는다). 프로그래매틱
229
245
  경로는 `runWork()` 의 `outboxRetentionMs`·`outboxPurgeIntervalMs`·`relayPollMs`.
230
246
  미발행 행은 절대 삭제되지 않는다.
231
- - **발행 실패는 행 단위로 격리된다(결정 306).** 한 행의 publish 가 실패해도(예:
232
- 페이로드가 NATS `max_payload` 1MiB 초과) 그 행만 미발행으로 남아 재시도되고,
233
- 뒤 행들은 정상 발행된다 — 한 행이 아웃박스 전체를 조용히 세우지 않는다. 실패는
234
- `gaon work` `⚠ 아웃박스 릴레이 오류` 로 신호된다( id 60s 스로틀).
247
+ - **발행 실패는 행 단위로 격리된다(결정 306 · 346).** 한 행의 publish 가 실패해도
248
+ (예: 페이로드가 NATS `max_payload` 1MiB 초과) 그 행만 claim 된 채 남아 리스
249
+ 만료(60s) 후 재시도되고, 뒤 행들은 정상 발행된다 — 한 행이 아웃박스 전체를
250
+ 조용히 세우지 않고, 실패 재시도도 폴링(1s) 해머링이 아니라 60s 로 자연
251
+ 스로틀된다. 실패는 `gaon work` 에 `⚠ 아웃박스 릴레이 오류` 로 신호된다.
235
252
  이벤트 페이로드는 1MiB 미만으로 유지한다 — 큰 데이터는 본문 대신 id 를 실어
236
253
  리스너가 조회하게 한다.
237
254
 
@@ -287,6 +304,11 @@ export default schedule((s) => {
287
304
  dedupe 키 + JetStream 중복 윈도우가 이를 1회로 수렴시킨다(결정 233).
288
305
  잡 자체는 재시도(백오프)가 있으니 **핸들러는 멱등**하게 짠다(같은 잡이 두 번
289
306
  처리돼도 안전하게).
307
+ - **`s.every` 위상은 리더 교체를 가로질러 보존된다(결정 349).** 마지막 발화
308
+ 시각이 KV(`gaon_scheduler`)에 남아, 새 리더는 밀렸으면 즉시 1회 발화하고
309
+ 아니면 잔여 시간만 기다린다 — 재선출마다 타이머가 리셋돼 리스 플래핑이
310
+ interval 보다 잦으면 every 잡이 영영 안 돌던 기아가 없다. 리스 갱신도 순단
311
+ 1~2회는 재시도 후에만 리더를 내려놓는다(불필요한 failover·리셋 억제).
290
312
  - **`gaon serve` 는 스케줄러를 돌리지 않는다** — 스케줄·리더 선출·아웃박스
291
313
  릴레이는 **`gaon work` 전용**이다. 웹 프로세스는 잡을 **발행**만 할 수 있고
292
314
  (`.later()`), 처리·스케줄은 워커가 한다. **운영에서** 스케줄이 안 도는 흔한
@@ -466,4 +488,9 @@ async create() {
466
488
  | 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ 없음·~1h36m) 명문화 |
467
489
  | 결정 310 | 스케줄 대상 잡 무인자 가드(§5) — 인자 필수 잡 등록을 컴파일 타임 거부(메서드 bivariance 로 통과해 `undefined` 인자 발화하던 구멍 차단) |
468
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 복원) |
469
496
  | §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |