@gaonjs/cli 0.5.0 → 0.10.1

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 (82) hide show
  1. package/dist/commands/check.d.ts +21 -2
  2. package/dist/commands/check.js +70 -7
  3. package/dist/commands/db.d.ts +3 -1
  4. package/dist/commands/db.js +8 -2
  5. package/dist/commands/g.d.ts +1 -1
  6. package/dist/commands/g.js +27 -3
  7. package/dist/commands/mcp.d.ts +15 -0
  8. package/dist/commands/mcp.js +78 -0
  9. package/dist/db/diff.js +5 -0
  10. package/dist/db/journal.d.ts +34 -0
  11. package/dist/db/journal.js +71 -0
  12. package/dist/db/migrate.d.ts +6 -1
  13. package/dist/db/migrate.js +120 -102
  14. package/dist/db/replay.d.ts +49 -0
  15. package/dist/db/replay.js +148 -0
  16. package/dist/db/status.d.ts +12 -0
  17. package/dist/db/status.js +61 -0
  18. package/dist/dev/index.d.ts +2 -0
  19. package/dist/dev/index.js +2 -0
  20. package/dist/dev/vite.d.ts +67 -0
  21. package/dist/dev/vite.js +126 -0
  22. package/dist/dev.d.ts +18 -0
  23. package/dist/dev.js +15 -0
  24. package/dist/doctor/agents-doc-index.d.ts +4 -0
  25. package/dist/doctor/agents-doc-index.js +80 -0
  26. package/dist/doctor/fixers/dependency-direction.d.ts +9 -0
  27. package/dist/doctor/fixers/dependency-direction.js +98 -0
  28. package/dist/doctor/fixers/index.d.ts +15 -0
  29. package/dist/doctor/fixers/index.js +66 -0
  30. package/dist/doctor/fixers/schema-filename.d.ts +14 -0
  31. package/dist/doctor/fixers/schema-filename.js +104 -0
  32. package/dist/doctor/fixers/types.d.ts +59 -0
  33. package/dist/doctor/fixers/types.js +15 -0
  34. package/dist/doctor/schema-filename.d.ts +6 -0
  35. package/dist/doctor/schema-filename.js +81 -0
  36. package/dist/doctor/types.d.ts +1 -1
  37. package/dist/doctor.d.ts +49 -0
  38. package/dist/doctor.js +179 -5
  39. package/dist/generate.js +2 -2
  40. package/dist/hub.d.ts +1 -1
  41. package/dist/index.d.ts +2 -1
  42. package/dist/index.js +50 -10
  43. package/dist/mcp/index.d.ts +7 -0
  44. package/dist/mcp/index.js +7 -0
  45. package/dist/mcp/server.d.ts +50 -0
  46. package/dist/mcp/server.js +102 -0
  47. package/dist/mcp/tools.d.ts +109 -0
  48. package/dist/mcp/tools.js +485 -0
  49. package/dist/scaffold/app.d.ts +5 -0
  50. package/dist/scaffold/app.js +172 -0
  51. package/dist/scaffold/controller.js +2 -2
  52. package/dist/scaffold/index.d.ts +2 -1
  53. package/dist/scaffold/index.js +2 -1
  54. package/dist/scaffold/job.d.ts +5 -0
  55. package/dist/scaffold/job.js +35 -0
  56. package/dist/scaffold/model.js +8 -8
  57. package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
  58. package/dist/templates/auth/registration.controller.ts.tpl +1 -1
  59. package/dist/templates/auth/session.controller.ts.tpl +1 -1
  60. package/dist/templates/auth/user.model.ts.tpl +1 -1
  61. package/dist/templates/project/AGENTS.md.tpl +214 -0
  62. package/dist/templates/project/agents/async.md.tpl +218 -0
  63. package/dist/templates/project/agents/data.md.tpl +556 -0
  64. package/dist/templates/project/agents/frontend.md.tpl +201 -0
  65. package/dist/templates/project/agents/realtime.md.tpl +157 -0
  66. package/dist/templates/project/agents/security.md.tpl +92 -0
  67. package/dist/templates/project/agents/testing.md.tpl +101 -0
  68. package/dist/templates/project/agents/web.md.tpl +177 -0
  69. package/dist/templates/project/apps/web/index.html.tpl +18 -0
  70. package/dist/templates/project/apps/web/main.ts.tpl +24 -0
  71. package/dist/templates/project/package.json.tpl +5 -2
  72. package/dist/templates/project/vite.config.ts.tpl +23 -0
  73. package/dist/tsResolve.js +1 -1
  74. package/dist/work.d.ts +2 -2
  75. package/dist/work.js +3 -1
  76. package/package.json +13 -11
  77. package/dist/__fixtures__/db-minimal/domain/schema/widgets.d.ts +0 -12
  78. package/dist/__fixtures__/db-minimal/domain/schema/widgets.js +0 -7
  79. package/dist/__fixtures__/db-minimal/gaon.config.d.ts +0 -2
  80. package/dist/__fixtures__/db-minimal/gaon.config.js +0 -11
  81. package/dist/check.d.ts +0 -29
  82. package/dist/check.js +0 -92
@@ -2,7 +2,8 @@ export type { ScaffoldFile } from './controller.js';
2
2
  export { controllerScaffold } from './controller.js';
3
3
  export { modelScaffold, modelScaffoldFiles, schemaScaffold } from './model.js';
4
4
  export { pageScaffold } from './page.js';
5
- export { jobScaffold } from './job.js';
5
+ export { jobScaffold, jobTestScaffold } from './job.js';
6
+ export { appScaffoldFiles, validateAppName } from './app.js';
6
7
  export { inflectModel, toCamel, toPascal, singularize, pluralize, type ModelNames, } from './inflect.js';
7
8
  import type { ScaffoldFile } from './controller.js';
8
9
  export interface WriteResult {
@@ -8,7 +8,8 @@ import { dirname, join, resolve } from 'node:path';
8
8
  export { controllerScaffold } from './controller.js';
9
9
  export { modelScaffold, modelScaffoldFiles, schemaScaffold } from './model.js';
10
10
  export { pageScaffold } from './page.js';
11
- export { jobScaffold } from './job.js';
11
+ export { jobScaffold, jobTestScaffold } from './job.js';
12
+ export { appScaffoldFiles, validateAppName } from './app.js';
12
13
  export { inflectModel, toCamel, toPascal, singularize, pluralize, } from './inflect.js';
13
14
  /**
14
15
  * 파일 계획을 실제로 쓴다. 기본은 기존 파일 skip(멱등 · 사고 방지).
@@ -1,3 +1,8 @@
1
1
  import type { ScaffoldFile } from './controller.js';
2
2
  /** 잡 이름(파스칼) → 파일·잡명 파생. */
3
3
  export declare function jobScaffold(pascalName: string): ScaffoldFile;
4
+ /**
5
+ * 잡 통합 테스트 골격 (결정 42) — 발행→실 처리 검증을 `expectJobProcessed`
6
+ * 한 호출로. 실 NATS 필수(§9 · 목업 금지) — `docker compose up -d nats`.
7
+ */
8
+ export declare function jobTestScaffold(pascalName: string): ScaffoldFile;
@@ -44,3 +44,38 @@ export function jobScaffold(pascalName) {
44
44
  ];
45
45
  return { path: `domain/jobs/${camel}.ts`, contents: lines.join('\n') };
46
46
  }
47
+ /**
48
+ * 잡 통합 테스트 골격 (결정 42) — 발행→실 처리 검증을 `expectJobProcessed`
49
+ * 한 호출로. 실 NATS 필수(§9 · 목업 금지) — `docker compose up -d nats`.
50
+ */
51
+ export function jobTestScaffold(pascalName) {
52
+ const trimmed = pascalName.trim();
53
+ if (!trimmed) {
54
+ throw new Error(`[gaon g job] 잡 이름이 비어 있습니다. 예: gaon g job SendEmail`);
55
+ }
56
+ const pascal = trimmed
57
+ .split(/[_\-\s]+/)
58
+ .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ''))
59
+ .join('');
60
+ const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
61
+ const lines = [
62
+ `// ${pascal} 잡 통합 테스트 — gaon g job (결정 42).`,
63
+ `// 실 NATS JetStream 필수(§9 · 목업 금지): docker compose up -d nats`,
64
+ `import { describe, it } from 'vitest'`,
65
+ `import { connectNats, expectJobProcessed } from 'gaonjs/testing'`,
66
+ `import ${camel} from '../../domain/jobs/${camel}.js'`,
67
+ ``,
68
+ `describe('${pascal} (실 NATS JetStream)', () => {`,
69
+ ` it('발행한 잡이 워커에서 처리된다', async () => {`,
70
+ ` const nats = await connectNats(process.env.NATS_URL ?? 'nats://localhost:4222')`,
71
+ ` try {`,
72
+ ` await expectJobProcessed(${camel}, () => ${camel}.later({ id: 'test' }), { nats })`,
73
+ ` } finally {`,
74
+ ` await nats.close()`,
75
+ ` }`,
76
+ ` })`,
77
+ `})`,
78
+ ``,
79
+ ];
80
+ return { path: `test/integration/${camel}.integration.test.ts`, contents: lines.join('\n') };
81
+ }
@@ -4,13 +4,13 @@
4
4
  // 스키마는 errata E-4 신규 컬럼 타입(decimal · enum · uuid)과 수식어(unique ·
5
5
  // nullable · default) 예시를 담아 새 개발자가 바로 참고할 수 있게 한다.
6
6
  //
7
- // 파일 위치 (CLAUDE.md §2 · rule 5):
8
- // · 스키마 → domain/schema/<name>.ts (테이블 정의 · tables.d.ts 원천)
9
- // · 모델 → domain/models/<name>.ts (Active Record · scopes/methods)
7
+ // 파일 위치 (CLAUDE.md §2 · rule 5 · 파일 네이밍 표 v0.16 §3.4):
8
+ // · 스키마 → domain/schema/<posts>.ts (파일명 = 테이블명 · camelCase 복수)
9
+ // · 모델 → domain/models/<Post>.ts (파일명 = 모델명 · PascalCase 단수)
10
10
  // 앱→도메인 import 만 허용되므로 이 위치가 유일한 정답이다.
11
11
  /** 스키마 스캐폴드 — E-4 컬럼 타입 예시를 함께 담는다. */
12
12
  export function schemaScaffold(names) {
13
- const { pascal, camel, plural } = names;
13
+ const { pascal, plural } = names;
14
14
  const lines = [
15
15
  `// ${pascal} 스키마 — gaon g model (M9-B).`,
16
16
  `// errata E-4 예시: unique · default · enum · nullable. 컬럼은 자유롭게 추가/삭제한다.`,
@@ -34,19 +34,19 @@ export function schemaScaffold(names) {
34
34
  ``,
35
35
  ];
36
36
  return {
37
- path: `domain/schema/${camel}.ts`,
37
+ path: `domain/schema/${plural}.ts`,
38
38
  contents: lines.join('\n'),
39
39
  };
40
40
  }
41
41
  /** 모델 스캐폴드 — scopes 예시를 담는다(체이닝 진입점 도우미). */
42
42
  export function modelScaffold(names) {
43
- const { pascal, camel, plural } = names;
43
+ const { pascal, plural } = names;
44
44
  const lines = [
45
45
  `// ${pascal} 모델 — gaon g model (M9-B).`,
46
46
  `// scopes 는 체인 어느 지점에서든 호출 가능하다(§4.4). E-4 체이닝 예시:`,
47
47
  `// ${pascal}.published().orderBy('createdAt', 'desc').limit(20).all()`,
48
48
  `import { model } from 'gaonjs/data'`,
49
- `import { ${plural} } from '../schema/${camel}.js'`,
49
+ `import { ${plural} } from '../schema/${plural}.js'`,
50
50
  ``,
51
51
  `export const ${pascal} = model(${plural}, {`,
52
52
  ` scopes: {`,
@@ -56,7 +56,7 @@ export function modelScaffold(names) {
56
56
  ``,
57
57
  ];
58
58
  return {
59
- path: `domain/models/${camel}.ts`,
59
+ path: `domain/models/${pascal}.ts`,
60
60
  contents: lines.join('\n'),
61
61
  };
62
62
  }
@@ -1,6 +1,6 @@
1
1
  // 인증 배선 — gaon g auth 스캐폴드.
2
2
  import type { AuthOptions } from 'gaonjs/web'
3
- import { User } from '../../domain/models/user.js'
3
+ import { User } from '../../domain/models/User.js'
4
4
 
5
5
  // 세션에 심긴 userId 로 사용자를 로드한다(§7). web 은 도메인을 loadUser 로 받는다.
6
6
  export const loadUser: AuthOptions['loadUser'] = async (id) =>
@@ -1,6 +1,6 @@
1
1
  // 회원가입 컨트롤러 — gaon g auth 스캐폴드.
2
2
  import { controller, hashPassword } from 'gaonjs/web'
3
- import { User } from '../../../domain/models/user.js'
3
+ import { User } from '../../../domain/models/User.js'
4
4
 
5
5
  export default controller({
6
6
  // GET /registration/new — 회원가입 폼
@@ -1,6 +1,6 @@
1
1
  // 세션 컨트롤러(로그인/로그아웃) — gaon g auth 스캐폴드.
2
2
  import { controller, verifyPassword } from 'gaonjs/web'
3
- import { User } from '../../../domain/models/user.js'
3
+ import { User } from '../../../domain/models/User.js'
4
4
 
5
5
  export default controller({
6
6
  // GET /session/new — 로그인 폼
@@ -1,5 +1,5 @@
1
1
  // 사용자 모델 — gaon g auth 스캐폴드.
2
2
  import { model } from 'gaonjs/data'
3
- import { users } from '../schema/user.js'
3
+ import { users } from '../schema/users.js'
4
4
 
5
5
  export const User = model(users, {})
@@ -0,0 +1,214 @@
1
+ # AGENTS.md — Gaon 프로젝트 AI 개발자 지침 (코어)
2
+
3
+ 이 문서는 **AI 코딩 에이전트**(Claude · Codex · Cursor · Copilot 등)와
4
+ 사람 개발자가 Gaon 프로젝트에서 작업할 때 참조하는 관례의 진입점이다.
5
+ 정본은 설계 문서(v0.15 동결 + errata E-1~E-5, v0.16 편입)이며, 관례
6
+ 문서는 **2층 구조**다 (결정 40):
7
+
8
+ - **이 파일 (코어)** — 절대 규칙 · 로직 배치 판단표 · 검증 루프 ·
9
+ 카테고리 색인. 여기엔 요약만 있다.
10
+ - **`agents/*.md` (카테고리)** — 시그니처·체이닝 표·정본 예시·함정의
11
+ 정본. **해당 영역 파일을 만지기 전에 반드시 그 카테고리 문서를 읽는다.**
12
+
13
+ **대상 프로젝트 = Gaon 프레임웍으로 만든 사용자 프로젝트**(`gaonjs`
14
+ 설치 후 `gaon new` 로 생성한 앱).
15
+
16
+ ## 0. 카테고리 색인 — 작업 전에 반드시 읽어라
17
+
18
+ 작업이 아래 영역에 걸치면, 코드를 만지기 **전에** 해당 파일을 읽는다.
19
+ 코어에는 요약만 있다 — 메서드 시그니처·표·정본 예시는 전부 카테고리
20
+ 파일에 있고, 표에 없는 API 를 추측하면 실패한다.
21
+
22
+ | 작업 영역 | 먼저 읽을 파일 |
23
+ |---|---|
24
+ | 스키마 · 모델 · 관계 · 쿼리/체이닝 · 서비스 · 마이그레이션 | `agents/data.md` |
25
+ | 라우트 · 컨트롤러 · params · JSON 액션 · 인증/비밀번호 | `agents/web.md` |
26
+ | 페이지 · 컴포넌트 · 컴포저블 · 레이아웃 · `api()` · bigint key | `agents/frontend.md` |
27
+ | 잡 · 이벤트 · 리스너 · 아웃박스 · 스케줄 | `agents/async.md` |
28
+ | 채널 · 프레즌스 · 허브 | `agents/realtime.md` |
29
+ | 테스트 작성·실행 (실 인프라 · `expectJobProcessed`) | `agents/testing.md` |
30
+ | 보안 기본값 · 탈출구(v-html · raw SQL) 사용 | `agents/security.md` |
31
+
32
+ 예: 회원가입 세로 조각(스키마+서비스+잡+컨트롤러+테스트)이면
33
+ `data · web · async · testing` 네 파일을 먼저 읽는다.
34
+
35
+ ## 1. The One Way 원칙
36
+
37
+ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍"** 이다
38
+ (v0.15 §1.2). 우선순위는 **AI 첫 시도 성공률 > 개발자 편의 > 구현
39
+ 편의** — 선택지가 생기면 정답이 하나가 되는 쪽을 고른다.
40
+
41
+ 1. **The One Way** — 모든 문제에 공식 답이 하나. 선택지는 탈출구
42
+ (escape hatch)로만 남긴다.
43
+ 2. **관례가 곧 문서** — 파일 위치·이름이 곧 동작. 설정은 관례를
44
+ 벗어날 때만 쓴다.
45
+ 3. **마법은 타입 추론으로** — 데코레이터·DI 컨테이너 없음. 스키마 →
46
+ 모델 → 컨트롤러 → Vue 까지 **타입 추론만**으로 흐른다 (`.gaon/`
47
+ 생성 파일만 예외 · 프레임웍이 관리).
48
+
49
+ **파일 하나 = 한 개념 · 명시적 import**. Nuxt식 자동 import 는 넣지
50
+ 않는다 (errata E-5 §2.4).
51
+
52
+ ## 2. 절대 규칙
53
+
54
+ 1. **TypeScript 전용 · 함수/객체 스타일.** JS 파일 추가 금지, 클래스형·
55
+ 데코레이터 금지 — `model()`·`controller()`·`job()`·`service()`·
56
+ `channel()` 함수형 API 만.
57
+ 2. **파사드 import 만.** 프레임웍 심볼은 `gaonjs/*` (`gaonjs/web`·
58
+ `gaonjs/data`·`gaonjs/vue`·`gaonjs/async`·`gaonjs/service`·
59
+ `gaonjs/testing`)에서 import 한다. `@gaonjs/*`(내부 스코프)·
60
+ `@inertiajs/vue3`(어댑터 내부 의존)는 앱 코드에서 직접 import 금지.
61
+ (설치명 `gaonjs` · CLI 명령 `gaon` — errata E-1.)
62
+ 3. **의존 방향 4규칙** (doctor 강제): ① 앱→`domain/` 허용 ②
63
+ `domain/`→앱 금지 ③ 앱→앱 금지 ④ 앱→`shared/` 허용 — `shared/` 는
64
+ 앱 import 금지, `domain/` 은 **타입 import 만** (공용 컴포넌트는
65
+ props 로만 받는 순수 UI).
66
+ 4. **보안 기본값(CORS·rate limit·CSRF)은 기본 켬.** 끄는 것은 명시적
67
+ 설정으로만 (`agents/security.md`).
68
+ 5. **테스트는 Docker 실인프라 필수** — DB·NATS 목업·인메모리 대체 절대
69
+ 금지 (§9 · `agents/testing.md`).
70
+ 6. **인증·폼은 Inertia SPA 방식** (SSR 아님). 폼은 `Inertia.post()` →
71
+ 서버 redirect. REST + `fetch()` 는 **API 앱(JWT) 전용**.
72
+ 7. **한 액션은 한 종류 응답만** (render 또는 JSON 또는 redirect —
73
+ 혼용 금지 · doctor response-mixing).
74
+ 8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`tables.d.ts`
75
+ 는 `gaon check`/`gaon dev` 가 재생성한다.
76
+
77
+ ### 2.1 파일 네이밍 표 (2026-07-24 승인 · 벤치마크 R1 실측 고정)
78
+
79
+ "Pascal 인가 camel 인가" 를 추측하지 않는다 — 아래 표가 전부다.
80
+
81
+ | 대상 | 파일명 | 예시 |
82
+ |---|---|---|
83
+ | 모델 | **PascalCase** | `domain/models/Post.ts` |
84
+ | 스키마 | **camelCase** (테이블명) | `domain/schema/posts.ts` · `posts_tags`→`postsTags.ts` |
85
+ | 서비스 | **camelCase** | `domain/services/registerUser.ts` |
86
+ | 잡 | **camelCase** | `domain/jobs/sendWelcomeMail.ts` |
87
+ | 이벤트 | **camelCase** | `domain/events/orderPlaced.ts` |
88
+ | 리스너 | **camelCase** | `domain/listeners/notifyAdmin.ts` |
89
+ | 메일 | **camelCase** | `domain/mails/welcome.ts` |
90
+ | 컨트롤러 | **camelCase** (액션 단위) | `apps/web/controllers/posts.ts` |
91
+ | Vue 페이지 | **PascalCase** (Route 이름) | `apps/web/pages/Posts/Index.vue` |
92
+ | Vue 컴포넌트 | **PascalCase** | `apps/web/components/PostCard.vue` |
93
+ | 컴포저블 | **use** + PascalCase | `apps/web/composables/usePostSearch.ts` |
94
+ | 레이아웃 | **PascalCase** | `apps/web/layouts/Default.vue` |
95
+ | 채널 | **camelCase** | `apps/web/channels/chatMessages.ts` |
96
+ | 마이그레이션 | **timestamp_action** | `db/migrations/20260724_add_posts.ts` |
97
+
98
+ - 다단어 테이블의 스키마 **파일명**은 camelCase (`posts_tags` →
99
+ `postsTags.ts`) — 파일 **안**의 `table('posts_tags', …)` 문자열은
100
+ 스네이크 그대로.
101
+ - **식별자** (결정 43): 함수·변수·메서드 = camelCase · 타입·Vue 컴포넌트 =
102
+ PascalCase · 상수·환경 변수 = UPPER_SNAKE. **DB 네이밍**(테이블명 ·
103
+ 컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
104
+ `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
105
+
106
+ ### 2.2 `gaon doctor` 검사 9종
107
+
108
+ 1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
109
+ 2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
110
+ 3. `dependency-direction` — 의존 방향 4규칙 위반
111
+ 4. `connections` — 커넥션 간 belongsTo · service 트랜잭션 (§4.5)
112
+ 5. `migration-diff` — 스키마 vs DB 상태 불일치
113
+ 6. `shared-composable-purity` — shared 안 `api`/`pageProps` import (결정 25)
114
+ 7. `no-auto-import` — 자동 import 설정 (E-5 §2.4)
115
+ 8. `schema-filename` — 스키마 파일명 camelCase 관례 (결정 38 · `--fix` 지원)
116
+ 9. `agents-doc-index` — 이 문서 색인(§0) ↔ `agents/` 실 파일 불일치 (결정 40)
117
+
118
+ ## 3. 로직 배치 One Way 판단표
119
+
120
+ ### 3.1 서버 로직 (정본 §5.3 원문)
121
+
122
+ > 1. 한 모델 안에서 끝나는 로직 → **모델 메서드**
123
+ > 2. 여러 모델·외부 API·트랜잭션이 얽히는 작업 흐름 → **`domain/services/`**
124
+ > 3. 컨트롤러에는 비즈니스 로직을 두지 않는다 — HTTP와의 **번역만**
125
+
126
+ ### 3.2 프론트 로직 (errata E-5 §2.1 원문 · 결정 25)
127
+
128
+ > 1. 컴포저블에는 **프론트 전용 로직만** — UI 상태, 브라우저 API,
129
+ > `api()` 호출 래핑, 채널 구독 래핑.
130
+ > 2. 비즈니스 로직(도메인 규칙·계산·트랜잭션)은 컴포저블에 두지 않는다
131
+ > — 서버의 `domain/`(모델 메서드·서비스)에만.
132
+ > 3. 한 컴포넌트 안에서만 쓰는 상태는 컴포저블로 뽑지 않는다 — 그냥
133
+ > `<script setup>` 에. 컴포저블은 **재사용될 때만**.
134
+
135
+ ### 3.3 데이터 경로 4종 (errata E-3 §2 원문)
136
+
137
+ | 상황 | 경로 | 근거 |
138
+ |---|---|---|
139
+ | 지금 페이지의 데이터를 다시 받기 (필터 변경·새로고침·무한 스크롤) | **Inertia partial reload** — 같은 액션 재호출, 필요한 props만 | §6.1 |
140
+ | 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) | §7 |
141
+ | 페이지와 무관한 데이터 요청 (자동완성·옵션 조회 등 앱 내부용) | **JSON 액션 + `api()` 클라이언트** (`agents/web.md`·`agents/frontend.md`) | E-3 |
142
+ | 외부에 공개하는 API (모바일 앱·서드파티) | **별도 API 앱 + JWT 옵션** | §3, §7 |
143
+
144
+ ### 3.4 잡 발행 위치 (결정 32)
145
+
146
+ 잡 `.later(...)` 은 **컨트롤러 · 서비스 · 리스너 어디서든** 발행 가능 —
147
+ 위치를 강제하지 않는다 (`agents/async.md` §2). DB 커밋 정합이 필요하면
148
+ 서비스 `afterCommit()` 또는 아웃박스.
149
+
150
+ ## 4. 검증 루프
151
+
152
+ 작업마다 실행한다:
153
+
154
+ ```bash
155
+ gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
156
+ gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
157
+ gaon doctor # 정적 검사 9종 (§2.2)
158
+ ```
159
+
160
+ ### 4.1 CLI 명령 (전 명령 `--json` 지원)
161
+
162
+ | 명령 | 역할 |
163
+ |---|---|
164
+ | `gaon new <name>` | 프로젝트 스캐폴드 |
165
+ | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·워처) |
166
+ | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) |
167
+ | `gaon g <type> <name>` | 스캐폴드: `auth`·`controller`·`model`·`page`·`job` |
168
+ | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
169
+ | `gaon check` / `test` / `doctor` | 검증 루프 |
170
+ | `gaon console` | 프로젝트 컨텍스트 REPL |
171
+ | `gaon jobs` | DLQ 조회·재적재 |
172
+ | `gaon mcp` | 내장 MCP 서버 — 도구: `list_routes`·`get_schema`·`run_migration`·`run_tests`·`read_agent_doc` |
173
+
174
+ `gaon mcp` 는 프레임웍이 자신을 AI 도구로 노출한다 (§7.5.3) — grep
175
+ 으로 더듬는 대신 프레임웍에게 직접 묻는다. `read_agent_doc` 은 §0
176
+ 카테고리 문서를 조회한다 (결정 40).
177
+
178
+ ## 5. npm 배포본 (2026-07-23 실측 · `npm view <pkg> version`)
179
+
180
+ | 패키지 | 버전 | 역할 |
181
+ |---|---|---|
182
+ | `gaonjs` | 0.6.0 | 파사드(설치 단위) · CLI `gaon` |
183
+ | `@gaonjs/cli` | 0.5.0 | 제너레이터·스캐폴딩·명령 라우팅 |
184
+ | `@gaonjs/data` | 0.3.0 | 스키마 DSL · 모델 · 마이그레이션 |
185
+ | `@gaonjs/config` | 0.1.0 | `gaon.config.ts`·`app.config.ts` |
186
+ | `@gaonjs/web` | 0.3.0 | Fastify 웹 레이어 · 인증 · JSON 액션 |
187
+ | `@gaonjs/vue` | 0.2.0 | Vue 어댑터 · pageProps · 타입드 `api()` |
188
+ | `@gaonjs/async` | 0.2.2 | 채널 · 프레즌스 · 허브 · 잡 · 스케줄 |
189
+ | `@gaonjs/core` | 0.1.4 | 메타데이터 · 직렬화 프리미티브(Hidden) |
190
+ | `@gaonjs/mail` / `storage` / `i18n` | 0.1.0 | 메일 · 파일 스토리지 · 다국어 |
191
+
192
+ ## 6. 원칙 · 엄수
193
+
194
+ - **추론 금지 · 사실에 입각.** 확인 안 된 것은 실행·측정으로 검증
195
+ 하거나 사용자에게 묻는다. 표에 없는 API 를 추측해서 쓰지 않는다 —
196
+ 카테고리 문서(§0)를 먼저 읽는다.
197
+ - **무조건 긍정 금지.** 문제가 있으면 문제라고 말하고, 비용·제약이
198
+ 있으면 반영 전에 명시한다.
199
+ - **에러 메시지는 수리 안내서.** "무엇이 잘못됐다" 가 아니라 → "어느
200
+ 파일에 무엇을 추가/수정하고 어떤 명령을 실행하라" 까지 (v0.15 §7.5.3).
201
+ - **관례가 곧 문서** — 관례를 어길 때만 설정을 쓴다.
202
+ - **정본·정오표 원문 우선.** 이 문서·카테고리 문서와 정본이 충돌하면
203
+ 정본·최신 정오표를 따른다.
204
+
205
+ ## 7. 참고 문서
206
+
207
+ - 설계 정본: `docs/gaondesignv0.15.md` (동결) + errata E-1~E-5
208
+ (E-1 파사드명 · E-2 실시간 TCP · E-3 JSON 액션/params · E-4 컬럼·
209
+ 체이닝 · E-5 컴포저블·레이아웃).
210
+ - 가이드: `docs/guides/*.md` (getting-started · data · data-flow ·
211
+ serialization · authentication · realtime · async · pipeline ·
212
+ operations · configuration).
213
+ - 프레임웍 구현 저장소의 AI 지침은 `CLAUDE.md` — 이 문서와 대상이
214
+ 다르다 (CLAUDE = 프레임웍 구현자용, AGENTS = 프레임웍 사용자용).
@@ -0,0 +1,218 @@
1
+ # agents/async.md — 비동기 (잡 · 이벤트 · 리스너 · 아웃박스 · 스케줄러)
2
+
3
+ > 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
4
+ > 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·표·예시의 정본은 이 파일이다.
5
+ > 대상 패키지: `@gaonjs/async` (파사드 import 는 `gaonjs/async`). 백본 = NATS JetStream ·
6
+ > 실행은 워커 프로세스(`gaon work`).
7
+
8
+ ## 정본 규칙
9
+
10
+ ### 1. 잡 (`job()`) (`packages/async/src/jobs.ts:146-185`)
11
+
12
+ 잡은 도메인 소속이다 — `domain/jobs/*.ts` 에 파일을 놓으면 등록이고,
13
+ 어느 앱에서 큐잉하든 같은 워커(`gaon work`)가 처리한다. 모델·서비스와
14
+ 같은 함수/객체 스타일(데코레이터 금지).
15
+
16
+ ```ts
17
+ // domain/jobs/sendWelcomeMail.ts — 파일명 camelCase (루트 §네이밍)
18
+ import { job } from 'gaonjs/async'
19
+
20
+ export const SendWelcomeMail = job(async (userId: bigint) => {
21
+ // 실 발송 로직 (예: gaonjs/mail 사용)
22
+ }, { retries: 3 })
23
+ ```
24
+
25
+ ```ts
26
+ await SendWelcomeMail.later(user.id) // 즉시 큐잉(인자 타입 그대로 추론)
27
+ await SendWelcomeMail.in('10m', user.id) // 지연 실행
28
+ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
29
+ ```
30
+
31
+ - **시그니처** — `job(handler, options?)`. 첫 인자는 평범한 async
32
+ 함수(`(...args) => Promise<void> | void`) — `defineJob` 이나
33
+ `{ perform }` 객체 형태가 아니다.
34
+ - **이름** — `options.name` 으로 명시하거나, 생략하면 `domain/jobs/`
35
+ 파일 로더가 **파일명**으로 채운다(`assignName`). 이름을 얻기 전까지
36
+ `.later()` 등을 호출하면 에러 — 파일로 두거나 `name` 을 직접 준다.
37
+ - **옵션** — `queue`(기본 `'default'`) · `retries`(기본 3) ·
38
+ `curve`(백오프 곡선 ms) · `jitter` · `concurrency`.
39
+ - **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
40
+ `gaon jobs retry <id>` 로 조회·재적재한다.
41
+ - 기본 백오프 곡선은 `[1s, 5s, 30s, 5m, 1h]` + 지터 0.2, 재시도 3
42
+ (총 4시도) — §7 · 벤치마크로 확정된 값.
43
+
44
+ ### 2. 잡 발행 위치 (결정 32 · 2026-07-24 종결)
45
+
46
+ 잡(`.later(...)`)은 **컨트롤러 · 서비스 · 리스너 어디서든 발행
47
+ 가능**하다. The One Way 로 발행 위치를 강제하지 않는다 — 사용 맥락에
48
+ 따라 선택한다 (v0.16 §12 결정 32).
49
+
50
+ - **컨트롤러** — 요청 응답 시 즉시 발행 (예: 회원가입 응답 후 환영
51
+ 메일 잡):
52
+
53
+ ```ts
54
+ async create() {
55
+ const user = await User.create(this.params(User.registerForm))
56
+ await SendWelcomeMail.later(user.id) // 컨트롤러에서 발행
57
+ return this.redirect('/dashboard')
58
+ }
59
+ ```
60
+
61
+ - **서비스** — 여러 컨트롤러 재사용 · 트랜잭션 · 복잡 로직:
62
+
63
+ ```ts
64
+ // domain/services/registerUser.ts — service() 상세는 agents/data.md §9
65
+ import { service } from 'gaonjs/service'
66
+
67
+ export const RegisterUser = service(async (input: RegisterInput) => {
68
+ const user = await User.create(input)
69
+ await SendWelcomeMail.later(user.id) // 서비스에서 발행
70
+ return user
71
+ })
72
+ // 컨트롤러에서: const user = await RegisterUser.call(input)
73
+ ```
74
+
75
+ - **리스너** — 이벤트 반응 (예: OrderPlaced 이벤트 → EmailReceipt 잡).
76
+
77
+ **어디서 발행하든 정합이다.** 컨트롤러 발행 = 즉시성 · 서비스 발행 =
78
+ 재사용성 · 리스너 발행 = 이벤트 기반. DB 커밋과 정합이 필요하면
79
+ 서비스 `afterCommit()`(`agents/data.md` §9) 또는 아웃박스(§4)를 쓴다.
80
+
81
+ ### 3. 이벤트와 리스너
82
+
83
+ 이벤트는 페이로드 shape 에서 타입이 **구조적으로** 추론된다. 리스너는
84
+ `domain/listeners/*.ts` 의 default export 이고, durable 컨슈머 id 는
85
+ **파일명**에서 채워진다(재시작해도 같은 컨슈머).
86
+
87
+ ```ts
88
+ // domain/events/orderPlaced.ts
89
+ import { event } from 'gaonjs/async'
90
+ import { t } from 'gaonjs/data'
91
+
92
+ export const OrderPlaced = event('order.placed', {
93
+ orderId: t.bigint(),
94
+ })
95
+ ```
96
+
97
+ ```ts
98
+ // domain/listeners/notifyAdmin.ts
99
+ import { on } from 'gaonjs/async'
100
+ import { OrderPlaced } from '../events/orderPlaced.js'
101
+
102
+ export default on(OrderPlaced, ({ orderId }) => {
103
+ // orderId 는 bigint 로 추론됨
104
+ })
105
+ ```
106
+
107
+ 이벤트 발행:
108
+
109
+ ```ts
110
+ await OrderPlaced.emit({ orderId: 1n })
111
+ ```
112
+
113
+ 여러 리스너가 같은 이벤트를 durable 컨슈머로 구독하며, 각자 재시도된다.
114
+
115
+ ### 4. 아웃박스 (트랜잭션 정합)
116
+
117
+ 이벤트를 DB 트랜잭션과 **원자적으로** 발행하려면 아웃박스를 쓴다.
118
+ `runInTransaction` 안에서 발행한 이벤트는 같은 트랜잭션의 아웃박스
119
+ 테이블에 스테이징되고, 트랜잭션이 커밋돼야 릴레이가 실제로 NATS 에
120
+ 발행한다. 트랜잭션이 롤백되면 이벤트도 사라진다.
121
+
122
+ ```ts
123
+ import { runInTransaction } from 'gaonjs/async'
124
+
125
+ await runInTransaction(async () => {
126
+ await Order.create({ /* … */ })
127
+ await OrderPlaced.emit({ orderId }) // 커밋돼야 실제 발행됨
128
+ })
129
+ ```
130
+
131
+ - 트랜잭션 안의 `emit` 은 `AsyncLocalStorage` 로 투명하게 감지돼
132
+ 아웃박스에 스테이징된다(별도 API 호출 불필요).
133
+ - 릴레이(`gaon work` 내장)가 `SKIP LOCKED` 로 아웃박스를 폴링해 발행
134
+ 한다 (기본 폴 1000ms · 배치 100).
135
+ - at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
136
+ 흡수한다.
137
+ - 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 워커 기동 시 보장된다.
138
+
139
+ ### 5. 스케줄러
140
+
141
+ `domain/schedule.ts` 에서 반복 작업을 선언한다. 실행 대상은 **항상 잡**
142
+ 이다(인라인 함수 금지). 여러 워커가 떠 있어도 **리더로 선출된 하나**만
143
+ 발행하므로 중복 실행이 없다.
144
+
145
+ ```ts
146
+ // domain/schedule.ts
147
+ import { schedule } from 'gaonjs/async'
148
+ import { CleanupExpiredSessions } from './jobs/cleanupExpiredSessions.js'
149
+ import { SendDailyDigest } from './jobs/sendDailyDigest.js'
150
+ import { SendWeeklyReport } from './jobs/sendWeeklyReport.js'
151
+
152
+ export default schedule((s) => {
153
+ s.every('10m', CleanupExpiredSessions) // 주기 실행
154
+ s.daily.at('04:00', SendDailyDigest) // 매일 특정 시각
155
+ s.cron('0 9 * * 1', SendWeeklyReport) // 크론 표현식 (5필드)
156
+ })
157
+ ```
158
+
159
+ | 빌더 | 설명 |
160
+ | --- | --- |
161
+ | `s.every(interval, Job)` | `'10m'`·`'700ms'` 등 주기 또는 ms |
162
+ | `s.daily.at('HH:MM', Job)` | 매일 지정 시각 |
163
+ | `s.cron('분 시 일 월 요일', Job)` | 5필드 크론 표현식 |
164
+
165
+ ### 6. 워커 프로세스 (`gaon work`)
166
+
167
+ 잡·리스너·스케줄러·아웃박스 릴레이를 한 프로세스로 조립한다. 운영
168
+ 프로세스 3종(serve·work·hub) 중 하나. SIGTERM/SIGINT 에 graceful
169
+ drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤 종료한다.
170
+
171
+ ## 정본 예시
172
+
173
+ 회원 가입 → 환영 메일 비동기 발송 세로 조각 (§7 원문 예시):
174
+
175
+ ```ts
176
+ // domain/jobs/sendWelcomeMail.ts
177
+ import { job } from 'gaonjs/async'
178
+
179
+ export const SendWelcomeMail = job(async (userId: bigint) => {
180
+ // 실 발송 (stub 도 OK — 파일 관례가 관건)
181
+ }, { retries: 3 })
182
+ ```
183
+
184
+ ```ts
185
+ // apps/web/controllers/registration.ts — 컨트롤러는 잡 발행만 (직접 발송 금지)
186
+ async create() {
187
+ const user = await RegisterUser.call(this.params(RegisterUser.form))
188
+ await SendWelcomeMail.later(user.id)
189
+ return this.redirect('/dashboard')
190
+ }
191
+ ```
192
+
193
+ 발행·처리를 검증하는 실 NATS 테스트는 `agents/testing.md` (결정 42 ·
194
+ `expectJobProcessed`) 를 따른다.
195
+
196
+ ## 알려진 함정
197
+
198
+ - **컨트롤러에서 메일·외부 발송 직접 호출 = 함정** — 컨트롤러는 잡
199
+ 발행만. nodemailer·resend·@sendgrid/mail 직접 import 금지.
200
+ - **클래스형 잡·데코레이터(`@Job`·`@Processor`) 금지** — `job()` 함수형만.
201
+ - **잡 파일 위치는 `domain/jobs/`** — 앱 폴더가 아니다 (잡은 도메인
202
+ 소속 · 어느 앱에서든 큐잉).
203
+ - **`export const <Pascal> = job(...)`** — export 없이 정의만 하면
204
+ 컨트롤러가 import 해 `.later()` 를 부를 수 없다.
205
+ - **스케줄 대상은 항상 잡** — `s.every('10m', async () => ...)` 인라인
206
+ 함수 금지.
207
+ - **커밋 전 발행 주의** — 트랜잭션 안에서 DB 확정 후에만 나가야 하는
208
+ 발행은 `afterCommit()` 또는 아웃박스로.
209
+ - **테스트에서 NATS 목업 금지** (§9) — 실 JetStream 에 접속한다
210
+ (`agents/testing.md`).
211
+
212
+ ## 관련 결정 번호
213
+
214
+ | 결정 | 내용 |
215
+ |---|---|
216
+ | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 모두 정합) |
217
+ | 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`agents/testing.md`) |
218
+ | §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |