@gaonjs/cli 0.18.0 → 0.21.2

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.
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** 소스에 _method 오버라이드 hack 이 있는지(단위 테스트 진입점 · 주석은 제외). */
3
+ export declare function usesMethodOverride(source: string): boolean;
4
+ /** apps/ 의 .vue·.ts 를 훑어 _method 오버라이드 hack 을 경고로 낸다. */
5
+ export declare function checkMethodOverride(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,75 @@
1
+ // @gaonjs/cli · doctor · _method 오버라이드 hack 검출 (결정 89 · 경고)
2
+ //
3
+ // Gaon 은 `_method`(Rails/Laravel 식 HTTP 메서드 스푸핑)를 지원하지 않는다 —
4
+ // Inertia 는 실 DELETE/PUT/PATCH 를 보낸다. `?_method=DELETE` 쿼리·`<input
5
+ // name="_method">` 폼 필드·`{ _method: 'DELETE' }` 를 쓰면 컴파일은 통과하지만
6
+ // 런타임이 조용히 파손된다(서버가 무시). 이 검사가 그 패턴을 경고로 잡는다.
7
+ // (auth-flow 벤치 (d) 실측 근거 · AGENTS 절대 규칙 6). 판정은 소스 텍스트 기반.
8
+ import { readdir, readFile } from 'node:fs/promises';
9
+ import { join, relative } from 'node:path';
10
+ // 메서드 오버라이드 hack 패턴: 쿼리(?_method=DELETE)·폼 필드(name="_method")·
11
+ // 객체 키(_method: 'DELETE' · 따옴표 유무 무관 · 값이 HTTP 메서드일 때만 — 오탐 방지).
12
+ const METHOD_OVERRIDE = /[?&]_method=|name\s*=\s*['"]_method['"]|['"]?\b_method\b['"]?\s*:\s*['"]?(?:DELETE|PUT|PATCH)/i;
13
+ // 주석을 제거한다(줄 주석·블록 주석·HTML/Vue 주석) — 안티패턴을 "쓰지 말라"고
14
+ // 설명하는 주석이 오탐을 내지 않도록. 실제 실행 코드만 남긴다.
15
+ function stripComments(source) {
16
+ return source
17
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
18
+ .replace(/<!--[\s\S]*?-->/g, ' ')
19
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // // 줄 주석(http:// 는 보존)
20
+ }
21
+ /** 소스에 _method 오버라이드 hack 이 있는지(단위 테스트 진입점 · 주석은 제외). */
22
+ export function usesMethodOverride(source) {
23
+ return METHOD_OVERRIDE.test(stripComments(source));
24
+ }
25
+ /** apps/ 의 .vue·.ts 를 훑어 _method 오버라이드 hack 을 경고로 낸다. */
26
+ export async function checkMethodOverride(cwd) {
27
+ const appsDir = join(cwd, 'apps');
28
+ const issues = [];
29
+ for (const abs of await walkSources(appsDir)) {
30
+ const source = await readFile(abs, 'utf8').catch(() => '');
31
+ if (!usesMethodOverride(source))
32
+ continue;
33
+ issues.push({
34
+ rule: 'method-override',
35
+ level: 'warning',
36
+ file: relative(cwd, abs),
37
+ message: `_method 오버라이드 hack 발견: ${relative(cwd, abs)} 이 \`_method\`(Rails/Laravel 식 HTTP ` +
38
+ `메서드 스푸핑)를 씁니다. Gaon 은 이를 지원하지 않아 컴파일은 통과해도 런타임이 조용히 ` +
39
+ `파손됩니다(서버가 무시 · 결정 89).\n` +
40
+ `→ Gaon 은 \`_method\` 를 지원하지 않습니다. HTML 폼이 못 보내는 메서드는 ` +
41
+ `\`router.delete(...)\` / \`useForm(...).delete(...)\` 를 쓰세요 (AGENTS 절대 규칙 6).`,
42
+ detail: { file: relative(cwd, abs) },
43
+ });
44
+ }
45
+ return { rule: 'method-override', issues };
46
+ }
47
+ /** apps/ 하위 .vue·.ts(선언·테스트 제외) 절대경로. */
48
+ async function walkSources(dir) {
49
+ const out = [];
50
+ const walk = async (d) => {
51
+ let entries;
52
+ try {
53
+ entries = await readdir(d, { withFileTypes: true });
54
+ }
55
+ catch {
56
+ return;
57
+ }
58
+ for (const e of entries) {
59
+ const abs = join(d, e.name);
60
+ if (e.isDirectory()) {
61
+ if (e.name === 'node_modules' || e.name === '.gaon')
62
+ continue;
63
+ await walk(abs);
64
+ }
65
+ else if (e.isFile()) {
66
+ if (e.name.endsWith('.d.ts') || e.name.endsWith('.test.ts'))
67
+ continue;
68
+ if (e.name.endsWith('.vue') || e.name.endsWith('.ts'))
69
+ out.push(abs);
70
+ }
71
+ }
72
+ };
73
+ await walk(dir);
74
+ return out.sort();
75
+ }
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점). */
3
+ export declare function referencedControllers(routesSource: string): Set<string>;
4
+ /** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
5
+ export declare function checkRouteRegistration(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,77 @@
1
+ // @gaonjs/cli · doctor · 라우트 미등록(고아 컨트롤러) 검사 (결정 79)
2
+ //
3
+ // apps/<앱>/controllers/<name>.ts 가 있는데 그 앱 routes.ts 어디에서도 참조되지
4
+ // 않으면(= `<name>#action` 대상도, `resource(s)('<name>')` 도 없음) 그 컨트롤러는
5
+ // 어떤 URL 로도 도달할 수 없다 — "라우트 미등록" 상태다. 컴파일은 통과하므로
6
+ // check 로는 안 잡히는 조용한 누락이라, 이 검사가 경고로 낸다(라우트가 없는
7
+ // 컨트롤러 파일은 배선을 깜빡한 흔한 실수 · §7.5.3 수리 안내).
8
+ //
9
+ // routes.ts 가 참조하는데 파일이 없는 반대 경우는 이미 generator 가 하드 에러로
10
+ // 잡으므로(gaon check 중 throw) 여기서는 고아 컨트롤러만 본다. 판정은 소스 텍스트
11
+ // 기반(가벼운 정적 검사) — routes.ts 를 실행하지 않는다.
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ /** routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점). */
15
+ export function referencedControllers(routesSource) {
16
+ const names = new Set();
17
+ // 메서드 대상: r.get('/', 'posts#index') → 'posts'
18
+ for (const m of routesSource.matchAll(/['"]([A-Za-z_]\w*)#\w+['"]/g))
19
+ names.add(m[1]);
20
+ // 리소스: r.resource('posts') · r.resources('posts')
21
+ for (const m of routesSource.matchAll(/\bresources?\s*\(\s*['"]([A-Za-z_]\w*)['"]/g))
22
+ names.add(m[1]);
23
+ return names;
24
+ }
25
+ /** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
26
+ export async function checkRouteRegistration(cwd) {
27
+ const appsDir = join(cwd, 'apps');
28
+ const issues = [];
29
+ for (const app of await safeListDirs(appsDir)) {
30
+ const routesPath = join(appsDir, app, 'routes.ts');
31
+ const routesSource = await readFile(routesPath, 'utf8').catch(() => undefined);
32
+ // routes.ts 가 없으면 참조 집합을 알 수 없어 diff 불가 — 건너뛴다
33
+ // (routes.ts 자체 부재는 다른 층의 관심사).
34
+ if (routesSource === undefined)
35
+ continue;
36
+ const referenced = referencedControllers(routesSource);
37
+ const controllersDir = join(appsDir, app, 'controllers');
38
+ for (const stem of await controllerStems(controllersDir)) {
39
+ if (referenced.has(stem))
40
+ continue;
41
+ issues.push({
42
+ rule: 'route-registration',
43
+ level: 'warning',
44
+ file: `apps/${app}/controllers/${stem}.ts`,
45
+ message: `라우트 미등록: apps/${app}/controllers/${stem}.ts 가 있는데 ` +
46
+ `apps/${app}/routes.ts 어디에서도 참조되지 않습니다 — 이 컨트롤러는 어떤 URL 로도 ` +
47
+ `도달할 수 없습니다(결정 79).\n` +
48
+ `→ apps/${app}/routes.ts 에 \`r.get('/${stem}', '${stem}#index')\`(또는 \`r.resources('${stem}')\`)를 ` +
49
+ `추가하거나, 쓰지 않는 컨트롤러라면 파일을 지우세요.`,
50
+ detail: { app, controller: stem },
51
+ });
52
+ }
53
+ }
54
+ return { rule: 'route-registration', issues };
55
+ }
56
+ /** apps/<앱>/controllers/*.ts 의 stem 목록(선언·테스트 제외). */
57
+ async function controllerStems(dir) {
58
+ try {
59
+ const entries = await readdir(dir, { withFileTypes: true });
60
+ return entries
61
+ .filter((e) => e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.d.ts') && !e.name.endsWith('.test.ts'))
62
+ .map((e) => e.name.slice(0, -'.ts'.length))
63
+ .sort();
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ async function safeListDirs(dir) {
70
+ try {
71
+ const entries = await readdir(dir, { withFileTypes: true });
72
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** routes.ts 소스에서 리터럴 라우트 경로 집합을 뽑는다(단위 테스트 진입점). */
3
+ export declare function routePaths(routesSource: string): Set<string>;
4
+ /** apps/ 를 훑어 라우트/에셋에 가려지는 정적 파일을 경고로 낸다. */
5
+ export declare function checkStaticCollision(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,84 @@
1
+ // @gaonjs/cli · doctor · 정적 파일 ↔ 예약 경로 충돌 검사 (결정 85 · 경고)
2
+ //
3
+ // apps/<앱>/static/ 의 파일은 앱 prefix 아래로 서빙되지만, **명시 라우트와
4
+ // /assets/ 는 항상 우선**한다(결정 85 · appStatic 은 폴백). 따라서 라우트가
5
+ // 이미 쓰는 경로(또는 /assets/*)에 같은 이름의 정적 파일을 두면 그 파일은
6
+ // **영원히 도달 불가**(조용히 라우트에 가려짐)다. 이 검사가 그 상태를 경고한다.
7
+ // 판정은 소스 텍스트 기반(정적 · routes.ts 를 실행하지 않는다).
8
+ import { readdir, readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ /** routes.ts 소스에서 리터럴 라우트 경로 집합을 뽑는다(단위 테스트 진입점). */
11
+ export function routePaths(routesSource) {
12
+ const paths = new Set();
13
+ // r.get('/path', ...) · r.post("/x/:id", ...) 등 — 첫 문자열 인자가 경로.
14
+ for (const m of routesSource.matchAll(/\br\.(?:get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g)) {
15
+ paths.add(m[1]);
16
+ }
17
+ // resource(s)('name') → REST 표준 경로의 콜리전 후보(/name).
18
+ for (const m of routesSource.matchAll(/\bresources?\s*\(\s*['"]([A-Za-z_]\w*)['"]/g)) {
19
+ paths.add(`/${m[1]}`);
20
+ }
21
+ return paths;
22
+ }
23
+ /** apps/ 를 훑어 라우트/에셋에 가려지는 정적 파일을 경고로 낸다. */
24
+ export async function checkStaticCollision(cwd) {
25
+ const appsDir = join(cwd, 'apps');
26
+ const issues = [];
27
+ for (const app of await safeListDirs(appsDir)) {
28
+ const staticDir = join(appsDir, app, 'static');
29
+ const files = await listFiles(staticDir, staticDir);
30
+ if (files.length === 0)
31
+ continue;
32
+ const routesSource = await readFile(join(appsDir, app, 'routes.ts'), 'utf8').catch(() => '');
33
+ const routes = routePaths(routesSource);
34
+ for (const rel of files) {
35
+ const urlPath = `/${rel}`;
36
+ const shadowedByRoute = routes.has(urlPath);
37
+ const shadowedByAssets = rel === 'assets' || rel.startsWith('assets/');
38
+ if (!shadowedByRoute && !shadowedByAssets)
39
+ continue;
40
+ const by = shadowedByAssets ? '프론트 에셋(/assets/*)' : `라우트 ${urlPath}`;
41
+ issues.push({
42
+ rule: 'static-collision',
43
+ level: 'warning',
44
+ file: `apps/${app}/static/${rel}`,
45
+ message: `정적 파일 가려짐: apps/${app}/static/${rel} 은 ${by} 에 가려져 서빙되지 않습니다 ` +
46
+ `(결정 85 · 라우트·에셋이 정적 폴더보다 우선).\n` +
47
+ `→ 정적 파일 이름을 바꾸거나, 그 경로를 라우트로 직접 응답하세요.`,
48
+ detail: { app, path: urlPath },
49
+ });
50
+ }
51
+ }
52
+ return { rule: 'static-collision', issues };
53
+ }
54
+ /** staticDir 하위 파일을 상대 경로(posix)로 나열. */
55
+ async function listFiles(dir, base) {
56
+ const out = [];
57
+ const walk = async (d) => {
58
+ let entries;
59
+ try {
60
+ entries = await readdir(d, { withFileTypes: true });
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ for (const e of entries) {
66
+ const abs = join(d, e.name);
67
+ if (e.isDirectory())
68
+ await walk(abs);
69
+ else if (e.isFile())
70
+ out.push(abs.slice(base.length + 1).split('\\').join('/'));
71
+ }
72
+ };
73
+ await walk(dir);
74
+ return out;
75
+ }
76
+ async function safeListDirs(dir) {
77
+ try {
78
+ const entries = await readdir(dir, { withFileTypes: true });
79
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
@@ -1,6 +1,6 @@
1
1
  // @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
2
2
  //
3
- // 14 검사(response-mixing · n-plus-one · dependency-direction · connections
3
+ // 17 검사(response-mixing · n-plus-one · dependency-direction · connections
4
4
  // · migration-diff · shared-composable-purity · no-auto-import · schema-filename
5
5
  // · agents-doc-index · column-casing · model-filename · page-filename · auth-wiring
6
6
  // · ui-kit-wiring)가 모두 이 DoctorCheck 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
3
3
  *
4
- * 14 검사를 조립한다:
4
+ * 17 검사를 조립한다:
5
5
  * 1) response-mixing (errata E-3 §C · 라이브)
6
6
  * 2) n-plus-one (errata E-4 (e))
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
@@ -16,6 +16,9 @@
16
16
  * 12) page-filename (§3.4 · 결정 32·46 · Vue 페이지 파일명 PascalCase)
17
17
  * 13) auth-wiring (§7 · 결정 59 · requireAuth ↔ app.config.ts auth 배선)
18
18
  * 14) ui-kit-wiring (§6.4 · 결정 76 · UI 킷 import ↔ apps/<앱>/style.css Tailwind 배선)
19
+ * 15) route-registration (결정 79 · 고아 컨트롤러 = routes.ts 미참조 경고)
20
+ * 16) static-collision (결정 85 · 정적 파일이 라우트/에셋에 가려짐 경고)
21
+ * 17) method-override (결정 89 · _method HTTP 스푸핑 hack 경고)
19
22
  *
20
23
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
21
24
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -42,6 +45,9 @@ import { checkModelFilename } from './doctor/model-filename.js';
42
45
  import { checkPageFilename } from './doctor/page-filename.js';
43
46
  import { checkAuthWiring } from './doctor/auth-wiring.js';
44
47
  import { checkUiKitWiring } from './doctor/ui-kit-wiring.js';
48
+ import { checkRouteRegistration } from './doctor/route-registration.js';
49
+ import { checkStaticCollision } from './doctor/static-collision.js';
50
+ import { checkMethodOverride } from './doctor/method-override.js';
45
51
  import { renderHuman, renderJson } from './doctor/reporter.js';
46
52
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
47
53
  import { makeResult, } from './doctor/types.js';
@@ -63,7 +69,7 @@ export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
63
69
  export { renderHuman, renderJson } from './doctor/reporter.js';
64
70
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
65
71
  /**
66
- * 실행할 검사 이름. 지정 없음(undefined) = 14개 모두.
72
+ * 실행할 검사 이름. 지정 없음(undefined) = 17개 모두.
67
73
  */
68
74
  const ALL_RULES = [
69
75
  'response-mixing',
@@ -80,6 +86,9 @@ const ALL_RULES = [
80
86
  'page-filename',
81
87
  'auth-wiring',
82
88
  'ui-kit-wiring',
89
+ 'route-registration',
90
+ 'static-collision',
91
+ 'method-override',
83
92
  ];
84
93
  const CHECKERS = {
85
94
  'response-mixing': checkResponseMixing,
@@ -96,6 +105,9 @@ const CHECKERS = {
96
105
  'page-filename': checkPageFilename,
97
106
  'auth-wiring': checkAuthWiring,
98
107
  'ui-kit-wiring': checkUiKitWiring,
108
+ 'route-registration': checkRouteRegistration,
109
+ 'static-collision': checkStaticCollision,
110
+ 'method-override': checkMethodOverride,
99
111
  };
100
112
  /**
101
113
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
package/dist/index.js CHANGED
@@ -100,10 +100,11 @@ function renderHelp(version = VERSION) {
100
100
  " gaon dev --json 통합 콘솔을 JSON 라인으로 출력(자동화)",
101
101
  " gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
102
102
  " gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
103
+ " gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
103
104
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
104
105
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
105
106
  " gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
106
- " gaon doctor 정적 검사 (14 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선)",
107
+ " gaon doctor 정적 검사 (17 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method)",
107
108
  " gaon doctor --json 자동화용 JSON 출력",
108
109
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
109
110
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -194,11 +195,15 @@ export function runCli(argv, opts = {}) {
194
195
  if (argv[0] === "serve") {
195
196
  const portIdx = argv.indexOf("--port");
196
197
  const hostIdx = argv.indexOf("--host");
198
+ const workersIdx = argv.indexOf("--workers");
197
199
  const port = portIdx >= 0 ? Number(argv[portIdx + 1]) : undefined;
198
200
  const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
201
+ // --workers <n|auto>: node:cluster 다중화(결정 84). env WEB_CONCURRENCY 도 가능.
202
+ const workersRaw = workersIdx >= 0 ? argv[workersIdx + 1] : undefined;
203
+ const workers = workersRaw === "auto" ? "auto" : workersRaw !== undefined ? Number(workersRaw) : undefined;
199
204
  // --dev: dev 전용 진단 라우트(/_gaon/health) 등록. gaon dev 가 자식
200
205
  // serve 에 넘긴다(결정 69 · dev-only by construction).
201
- void runServeCommand({ json: argv.includes("--json"), port, host, dev: argv.includes("--dev") }).catch((err) => {
206
+ void runServeCommand({ json: argv.includes("--json"), port, host, workers, dev: argv.includes("--dev") }).catch((err) => {
202
207
  const msg = err instanceof Error ? err.message : String(err);
203
208
  process.stderr.write(` ✗ gaon serve 실패: ${msg}\n`);
204
209
  process.exitCode = 1;
@@ -230,7 +235,7 @@ export function runCli(argv, opts = {}) {
230
235
  });
231
236
  return;
232
237
  }
233
- // `gaon doctor` — 정적 검사(M9-E · 14 검사). --check=<이름>[,<이름>...] 로
238
+ // `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
234
239
  // 선택 실행, --json 은 자동화 파싱용.
235
240
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
236
241
  if (argv[0] === "doctor") {
package/dist/serve.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
3
+ * (availableParallelism). 0·음수·비수치는 1 로 떨어진다(안전 기본). 결정 84.
4
+ */
5
+ export declare function resolveWorkerCount(workers: number | 'auto' | undefined, env?: NodeJS.ProcessEnv): number;
1
6
  export interface ServeCommandOptions {
2
7
  readonly cwd?: string;
3
8
  readonly json?: boolean;
@@ -5,6 +10,11 @@ export interface ServeCommandOptions {
5
10
  readonly port?: number;
6
11
  /** 리슨 호스트. 우선순위: 옵션 > config.web.host > '0.0.0.0'. */
7
12
  readonly host?: string;
13
+ /**
14
+ * 워커 수(node:cluster). 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수.
15
+ * 2 이상이면 프라이머리가 fork 해 다중화한다(결정 84).
16
+ */
17
+ readonly workers?: number | 'auto';
8
18
  /**
9
19
  * dev 모드(gaon dev 자식). true 면 dev 전용 진단 라우트(/_gaon/health)를
10
20
  * 등록한다. 운영 serve 는 이 플래그 없이 실행되어 진단 라우트가 노출되지
package/dist/serve.js CHANGED
@@ -8,13 +8,32 @@
8
8
  * env 를 먼저 로드한다 — .env 의 값이 gaon.config.ts 안의 env('KEY') 에
9
9
  * 들어갈 수 있어야 하기 때문.
10
10
  *
11
- * 워커 (--workers, WEB_CONCURRENCY)는 v0.8 정본이지만 컨테이너 안 기본
12
- * 1워커라 M9-A 단일 프로세스만 다룬다. node:cluster 통합은 후속(M9-C).
11
+ * 워커 다중화(--workers · WEB_CONCURRENCY)는 node:cluster 처리한다(결정 84):
12
+ * 기본 1(컨테이너 기본). 2 이상이면 프라이머리가 N 워커를 fork 하고 OS 가
13
+ * 연결을 분산(cluster 라운드로빈)한다. 워커가 예기치 않게 죽으면 교체 fork,
14
+ * SIGTERM/SIGINT 에 워커들을 graceful drain 후 종료한다.
13
15
  */
16
+ import cluster from 'node:cluster';
17
+ import { availableParallelism } from 'node:os';
14
18
  import { loadDotEnv } from '@gaonjs/core';
15
19
  import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
16
20
  import { registerTsResolve } from './tsResolve.js';
17
21
  import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
22
+ /**
23
+ * 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
24
+ * (availableParallelism). 0·음수·비수치는 1 로 떨어진다(안전 기본). 결정 84.
25
+ */
26
+ export function resolveWorkerCount(workers, env = process.env) {
27
+ const raw = workers ?? env.WEB_CONCURRENCY;
28
+ if (raw === undefined || raw === '')
29
+ return 1;
30
+ if (raw === 'auto')
31
+ return Math.max(1, availableParallelism());
32
+ const n = typeof raw === 'number' ? raw : Number(raw);
33
+ if (!Number.isFinite(n) || n <= 0)
34
+ return 1;
35
+ return Math.floor(n);
36
+ }
18
37
  function humanEvent(e) {
19
38
  switch (e.kind) {
20
39
  case 'starting': {
@@ -24,17 +43,81 @@ function humanEvent(e) {
24
43
  }
25
44
  case 'listening':
26
45
  return ` ▶ 리슨 중 — ${e.url} (Ctrl+C 로 종료)`;
46
+ case 'cluster':
47
+ return ` gaon serve · 클러스터 — 워커 ${e.workers}개 fork (node:cluster)`;
48
+ case 'worker-exit':
49
+ return ` ⚠ 워커 종료(pid ${e.pid ?? '?'} · code ${e.code}${e.signal ? ` · ${e.signal}` : ''})${e.restarted ? ' — 교체 fork' : ''}`;
27
50
  case 'stopping':
28
51
  return ' gaon serve · 종료 중 (graceful) ...';
29
52
  case 'stopped':
30
53
  return ' gaon serve · 종료';
31
54
  }
32
55
  }
56
+ /**
57
+ * 클러스터 프라이머리 — N 워커를 fork 하고 감독한다. 워커가 예기치 않게 죽으면
58
+ * 교체 fork(복원력), SIGTERM/SIGINT 에 워커들을 SIGTERM 으로 graceful drain 한 뒤
59
+ * (상한 `GAON_WORKER_DRAIN_MS`, 기본 30s) SIGKILL 로 강제 종료한다. 결정 84.
60
+ */
61
+ async function runClusterPrimary(workerCount, opts) {
62
+ const json = opts.json ?? false;
63
+ const signals = opts.signals ?? process;
64
+ const drainMs = process.env.GAON_WORKER_DRAIN_MS ? Number(process.env.GAON_WORKER_DRAIN_MS) : 30_000;
65
+ const emit = (e) => {
66
+ if (json)
67
+ process.stdout.write(JSON.stringify(e) + '\n');
68
+ else
69
+ process.stdout.write(humanEvent(e) + '\n');
70
+ };
71
+ emit({ kind: 'cluster', workers: workerCount });
72
+ let shuttingDown = false;
73
+ for (let i = 0; i < workerCount; i++)
74
+ cluster.fork();
75
+ cluster.on('exit', (worker, code, signal) => {
76
+ if (shuttingDown)
77
+ return;
78
+ // 예기치 않은 종료 → 교체 fork 로 워커 수를 유지한다.
79
+ emit({ kind: 'worker-exit', pid: worker.process.pid, code, signal, restarted: true });
80
+ cluster.fork();
81
+ });
82
+ await new Promise((resolvePromise) => {
83
+ const stop = () => {
84
+ if (shuttingDown)
85
+ return;
86
+ shuttingDown = true;
87
+ signals.off('SIGINT', stop);
88
+ signals.off('SIGTERM', stop);
89
+ emit({ kind: 'stopping' });
90
+ for (const w of Object.values(cluster.workers ?? {}))
91
+ w?.kill('SIGTERM');
92
+ const killTimer = setTimeout(() => {
93
+ for (const w of Object.values(cluster.workers ?? {}))
94
+ w?.kill('SIGKILL');
95
+ }, drainMs);
96
+ const check = () => {
97
+ if (Object.keys(cluster.workers ?? {}).length === 0) {
98
+ clearTimeout(killTimer);
99
+ emit({ kind: 'stopped' });
100
+ resolvePromise();
101
+ }
102
+ };
103
+ cluster.on('exit', check);
104
+ check();
105
+ };
106
+ signals.on('SIGINT', stop);
107
+ signals.on('SIGTERM', stop);
108
+ });
109
+ }
33
110
  /**
34
111
  * `gaon serve` 진입점. loadDotEnv → loadGaonConfig → wireGaon → listen →
35
112
  * SIGINT 대기 → graceful close. 예외는 stderr + exit 1.
36
113
  */
37
114
  export async function runServeCommand(opts = {}) {
115
+ // 워커 다중화(결정 84): 2 이상이고 이 프로세스가 프라이머리면 감독만 한다.
116
+ // 워커(cluster.isWorker)와 단일 프로세스(count=1)는 아래 서버 본문을 실행한다.
117
+ const workerCount = resolveWorkerCount(opts.workers);
118
+ if (workerCount > 1 && cluster.isPrimary) {
119
+ return runClusterPrimary(workerCount, opts);
120
+ }
38
121
  const cwd = opts.cwd ?? process.cwd();
39
122
  const json = opts.json ?? false;
40
123
  const signals = opts.signals ?? process;
@@ -85,4 +168,9 @@ export async function runServeCommand(opts = {}) {
85
168
  signals.on('SIGINT', stop);
86
169
  signals.on('SIGTERM', stop);
87
170
  });
171
+ // 클러스터 워커는 graceful close 후에도 cluster IPC 채널이 이벤트 루프를 잡아
172
+ // 프로세스가 안 죽는다 → 프라이머리가 워커 소멸을 감지 못 해 매달린다. close 가
173
+ // 끝난 뒤 명시적으로 종료한다(안전). 단일 프로세스는 자연 종료(호출 안 함). 결정 84.
174
+ if (cluster.isWorker)
175
+ process.exit(0);
88
176
  }
@@ -0,0 +1,12 @@
1
+ # 컨테이너 빌드에서 제외 — 호스트 산출물이 이미지로 새지 않게 한다.
2
+ # node_modules 는 반드시 제외: 호스트(예: darwin/arm64) 네이티브 바이너리가
3
+ # 리눅스 컨테이너로 복사되면 esbuild 등이 깨진다(이미지 안에서 재설치).
4
+ node_modules
5
+ **/node_modules
6
+ .gaon
7
+ dist
8
+ .git
9
+ .env
10
+ .env.*
11
+ npm-debug.log*
12
+ *.log
@@ -2,8 +2,8 @@
2
2
 
3
3
  이 문서는 **AI 코딩 에이전트**(Claude · Codex · Cursor · Copilot 등)와
4
4
  사람 개발자가 Gaon 프로젝트에서 작업할 때 참조하는 관례의 진입점이다.
5
- 정본은 설계 문서(v0.15 동결 + errata E-1~E-5, v0.16 편입)이며, 관례
6
- 문서는 **2층 구조**다 (결정 40):
5
+ 정본은 설계 문서(`docs/gaondesignv0.17.md` · v1.0 출시 기준 스냅샷 ·
6
+ v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구조**다 (결정 40):
7
7
 
8
8
  - **이 파일 (코어)** — 절대 규칙 · 로직 배치 판단표 · 검증 루프 ·
9
9
  카테고리 색인. 여기엔 요약만 있다.
@@ -104,7 +104,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
104
104
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
105
105
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
106
106
 
107
- ### 2.2 `gaon doctor` 검사 14
107
+ ### 2.2 `gaon doctor` 검사 17
108
108
 
109
109
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
110
110
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -120,6 +120,9 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
120
120
  12. `page-filename` — Vue 페이지 파일명 PascalCase 관례 (결정 32·46)
121
121
  13. `auth-wiring` — requireAuth/this.auth 사용 ↔ `app.config.ts` 인증 배선 (결정 59)
122
122
  14. `ui-kit-wiring` — UI 킷 컴포넌트 import ↔ `apps/<앱>/style.css` Tailwind 배선 (결정 76 · 경고)
123
+ 15. `route-registration` — 고아 컨트롤러(파일은 있는데 `routes.ts` 미참조 · 도달 불가) (결정 79 · 경고)
124
+ 16. `static-collision` — 정적 파일(`apps/<앱>/static/`)이 라우트/에셋에 가려져 도달 불가 (결정 85 · 경고)
125
+ 17. `method-override` — `_method` HTTP 메서드 스푸핑 hack(Gaon 미지원 · router.delete 를 쓰라) (결정 89 · 경고)
123
126
 
124
127
  ## 3. 로직 배치 One Way 판단표
125
128
 
@@ -160,7 +163,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
160
163
  ```bash
161
164
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
162
165
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
163
- gaon doctor # 정적 검사 14종 (§2.2)
166
+ gaon doctor # 정적 검사 17종 (§2.2)
164
167
  ```
165
168
 
166
169
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -210,7 +213,8 @@ gaon doctor # 정적 검사 14종 (§2.2)
210
213
 
211
214
  ## 7. 참고 문서
212
215
 
213
- - 설계 정본: `docs/gaondesignv0.15.md` (동결) + errata E-1~E-5
216
+ - 설계 정본: `docs/gaondesignv0.17.md` (v1.0 출시 기준 스냅샷 · 결정 31~89) ·
217
+ 이력 동결 = `gaondesignv0.16.md`·`v0.15.md` + errata E-1~E-5
214
218
  (E-1 파사드명 · E-2 실시간 TCP · E-3 JSON 액션/params · E-4 컬럼·
215
219
  체이닝 · E-5 컴포저블·레이아웃).
216
220
  - 가이드: `docs/guides/*.md` (getting-started · data · data-flow ·
@@ -85,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
85
85
 
86
86
  ```bash
87
87
  gaon check # .gaon 재생성 후 타입 검사 (CI 정합)
88
- gaon doctor # 정적 검사 5종 (응답·N+1·의존·커넥션·마이그)
88
+ gaon doctor # 정적 검사 17종 (응답·N+1·의존·커넥션·마이그·순수·자동import·파일명/컬럼·인증·UI킷·라우트 · 상세 AGENTS §2.2)
89
89
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
90
90
  ```
91
91
 
@@ -0,0 +1,30 @@
1
+ # {{PROJECT_NAME}} 운영 이미지 — Node 22 · pnpm · TS 네이티브(gaon serve)
2
+ #
3
+ # gaonjs 는 TS 네이티브(Node 타입 스트리핑)라 별도 tsc 빌드가 없다. `vite build`
4
+ # 로 프론트 번들만 만들고, 런타임은 소스 + 번들을 `gaon serve` 로 그대로 돌린다.
5
+ # 웹·워커·허브 프로세스는 compose.prod.yaml 이 같은 이미지로 command 만 바꿔 띄운다.
6
+
7
+ FROM node:22-slim AS base
8
+ ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
9
+ RUN corepack enable
10
+ WORKDIR /app
11
+
12
+ # 1) 의존 설치 — lockfile 로 재현 가능하게(빌드에 dev 의존 필요).
13
+ FROM base AS deps
14
+ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
15
+ RUN pnpm install --frozen-lockfile
16
+
17
+ # 2) 프론트 번들 빌드(vite build).
18
+ FROM base AS build
19
+ COPY --from=deps /app/node_modules ./node_modules
20
+ COPY . .
21
+ RUN pnpm build
22
+
23
+ # 3) 런타임 — 소스 + 번들 + 의존을 그대로 실행.
24
+ FROM base AS runtime
25
+ ENV NODE_ENV=production
26
+ COPY --from=build /app ./
27
+ # 웹 서버 포트(gaon.config.ts 의 web.port · 기본 3000). 프록시 뒤에 둔다.
28
+ EXPOSE 3000
29
+ # 웹 프로세스. 워커(gaon work)·허브(gaon hub)는 compose.prod.yaml 의 별도 서비스.
30
+ CMD ["pnpm", "serve"]
@@ -143,6 +143,10 @@ export const PlaceOrder = service(async (input: { name: string }) => {
143
143
  - at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
144
144
  흡수한다.
145
145
  - 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 워커 기동 시 보장된다.
146
+ - 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
147
+ (결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `gaon work` 의
148
+ `outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(운영 상세는
149
+ `docs/guides/operations.md`). 미발행 행은 절대 삭제되지 않는다.
146
150
 
147
151
  ### 5. 스케줄러
148
152
 
@@ -193,8 +193,8 @@ export const posts = table('posts', {
193
193
  | `pluck` | `(col)` | `Promise<Row[col][]>` | 단일 컬럼 배열 · 정렬·limit·offset 반영 |
194
194
  | `select` | `(['a', 'b'])` | `SelectChain<Row, K>` | 부분 컬럼 — `first`/`all` 이 `Pick<Row, K>` **plain 행** 반환 (메서드·관계·update 없음) |
195
195
  | `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
196
- | `updateAll` | `(patch)` | `Promise<bigint>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수 반환. limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
197
- | `deleteAll` | `()` | `Promise<bigint>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
196
+ | `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
197
+ | `deleteAll` | `()` | `Promise<number>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
198
198
 
199
199
  **집계·조인 그룹** (`Chain` · M2E · 결정 34):
200
200
 
@@ -218,18 +218,35 @@ export const posts = table('posts', {
218
218
  | 메서드 | 시그니처 | 반환 | 비고 |
219
219
  |---|---|---|---|
220
220
  | `create` | `(data)` | `Promise<Rec>` | `t.id()`·`t.timestamps()`·`.default()` 컬럼은 입력에서 선택적 (`InsertOf`) |
221
- | `batchInsert` | `(rows)` | `Promise<Rec[]>` | **벌크 삽입** (M2F · 결정 35) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · 넣은 순서대로 Rec 반환. 빈 배열은 DB 무접촉 `[]`. beforeCreate 훅은 각 row 에 적용 |
222
- | `insertOrIgnore` | `(row \| rows)` | `Promise<Rec[]>` | **멱등 삽입** (M2F) — `ON CONFLICT DO NOTHING` · UNIQUE/PK 충돌 행은 건너뛰고 **실제로 삽입된 Rec 만** 반환. 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
223
- | `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<Rec[]>` | **있으면 갱신 없으면 삽입** (M2F) — `ON CONFLICT DO UPDATE`. `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 `excluded` 로 덮음 · 갱신 대상 없으면 DO NOTHING |
221
+ | `batchInsert` | `(rows)` | `Promise<BulkResult>` | **벌크 삽입** (M2F · 결정 35 → BulkResult 결정 90) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · `{ count }`(삽입 행 수) 반환. 빈 배열은 DB 무접촉 `{ count: 0 }`. beforeCreate 훅은 각 row 에 적용 |
222
+ | `insertOrIgnore` | `(row \| rows)` | `Promise<BulkResult>` | **멱등 삽입** (M2F) — 충돌 행은 건너뛰고 `count` = **실제로 삽입된 수**(PG=`ON CONFLICT DO NOTHING` · MySQL=`INSERT IGNORE`). 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
223
+ | `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<BulkResult>` | **있으면 갱신 없으면 삽입** (M2F) — PG=`ON CONFLICT DO UPDATE` · MySQL=`ON DUPLICATE KEY UPDATE`. `count` = 처리된 입력 행 수(삽입+갱신 · 갱신 대상 없으면 실 삽입 수). `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 덮음 |
224
224
  | `find` | `(id)` | `Promise<Rec>` | 없으면 **throw** — undefined 를 허용하려면 `where('id', '=', id).first()` |
225
225
  | `query` | `()` | Kysely `SelectQueryBuilder` | §5 탈출구 |
226
226
 
227
- > 벌크 삽입 3종(`batchInsert`·`insertOrIgnore`·`upsert`) **RETURNING
228
- > 지원하는 커넥션(Postgres)** 에서만 된다 삽입/무시/갱신된집합을
229
- > 정확히 복원할 있는 방언이 RETURNING 뿐이라, RETURNING 커넥션에서는
230
- > 어림하지 않고 수리 안내와 함께 **throw** 한다(결정 35 · §7.5.3). 삽입 계열은
227
+ > 벌크 3종은 **전 방언 통일 `BulkResult`**(`{ count, meta? }`) 돌려준다(결정 90 ·
228
+ > 원안의 "비 RETURNING 방언 throw" 폐기). `count` **처리된 입력수**로 방언
229
+ > 무관 같은 의미다 MySQL upsert 원시 `affectedRows`(삽입 1·갱신 2·무변경 0/1)를
230
+ > 그대로 노출하지 않고 정규화한다. 방언 원시값이 필요하면 `meta`(PG `returnedIds` ·
231
+ > MySQL `firstInsertId`·`affectedRows`)를 쓴다. **행 자체가 필요하면**: 소량은
232
+ > `create()`(단건 · 전 방언 Rec 반환), 대량은 유니크 키로 재조회 — **id 범위 추정
233
+ > 재조회는 금지**(MySQL auto-increment 는 동시 삽입에서 연속 보장이 없다). 삽입 계열은
231
234
  > `create` 처럼 루트 전용 — `where` 필터와 무관하므로 체인 종단이 아니다.
232
235
 
236
+ **개수·집계 반환 타입 — 영역별로 다르다** (결정 90·91 · 헷갈리지 말 것):
237
+
238
+ | 영역 | 반환 타입 | 근거 |
239
+ |---|---|---|
240
+ | 쓰기 개수 (`BulkResult.count` · `updateAll` · `deleteAll`) | `number` | 처리 행 수 — 2^53 초과 실무 없음 · JSON 직렬화 |
241
+ | 읽기 집계 `count()` | `bigint` | E-4 정본 |
242
+ | 읽기 집계 `sum()` · `avg()` | `string` | 무손실(정밀도 보존) |
243
+ | 읽기 집계 `min()` · `max()` | 컬럼 타입 따름 | E-4 정본 |
244
+
245
+ > **왜 sum/avg 가 `number` 가 아닌가**: 양 방언 실측 결과 집계 체인은 이미 방언
246
+ > 일관이라(silent split 없음) 고칠 문제가 없고, `string→number` 강제는 2^53 초과
247
+ > 합계에서 **정밀도를 잃는**(lossy) breaking 이라 "어림 금지" 원칙에 어긋나 기각했다
248
+ > (결정 91). 쓰기 개수(number)와 읽기 집계(string/bigint)는 축이 다르다.
249
+
233
250
  **레코드(`Rec`) 내장** (`model.ts:47-52`):
234
251
 
235
252
  - `rec.update(patch)` — `Partial<Row>` 부분 갱신, 갱신된 Rec 반환.
@@ -546,14 +563,15 @@ const slim = await Post.select(['id', 'title']).all() // Pick<Row, 'id' | 'titl
546
563
  // 삭제 — 단건은 레코드, 벌크는 deleteAll (M2C)
547
564
  const post = await Post.find(id)
548
565
  await post.delete()
549
- const removed = await Post.where('published', '=', false).deleteAll() // bigint
566
+ const removed = await Post.where('published', '=', false).deleteAll() // number
550
567
  const touched = await Post.where('authorId', '=', me.id).updateAll({ published: true })
551
568
 
552
- // 벌크 삽입·UPSERT — 루트 전용 (M2F · 결정 35)
553
- const seeded = await Post.batchInsert(rows) // 단일 INSERT · Rec[] (순서 보존)
554
- const fresh = await Post.insertOrIgnore(rows) // 충돌 건너뜀 · 실 삽입만
555
- await Post.upsert(rows, { onConflict: 'slug', update: ['title', 'body'] })
569
+ // 벌크 삽입·UPSERT — 루트 전용 (M2F · 결정 35 → BulkResult 결정 90 · 전 방언 통일)
570
+ const seeded = await Post.batchInsert(rows) // { count } (삽입 )
571
+ const fresh = await Post.insertOrIgnore(rows) // { count } (실 삽입만 · 충돌 건너뜀)
572
+ const { count } = await Post.upsert(rows, { onConflict: 'slug', update: ['title', 'body'] })
556
573
  await Post.upsert({ id, title, body }) // onConflict 생략 = 기본 키(id)
574
+ // 삽입된 행이 필요하면: 소량은 create(), 대량은 유니크 키로 재조회 (id 범위 추정 금지)
557
575
  ```
558
576
 
559
577
  ## 알려진 함정
@@ -568,6 +586,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
568
586
  `foreignKey`/`otherKey` 를 명시한다.
569
587
  - **`updateAll`/`deleteAll` 에 limit·offset·orderBy 가 걸려 있으면 throw** —
570
588
  행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`.
589
+ - **벌크 3종은 행이 아니라 `{ count }` 를 준다** (결정 90) — 반환을 `Rec[]` 처럼
590
+ 다루지 말 것. 삽입된 행이 필요하면 소량은 `create()`, 대량은 유니크 키 재조회.
591
+ `throw` 하던 옛 계약(비 RETURNING 방언)은 폐기 — MySQL 도 그냥 `count` 를 준다.
571
592
  - **loop 안 관계 lazy 호출 = N+1** — doctor **n-plus-one** 검사가 잡는다.
572
593
  목록은 `include()` 로.
573
594
  - **스키마 파일명은 camelCase** — 테이블 `posts_tags` → 파일 `postsTags.ts`
@@ -588,8 +609,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
588
609
  | 결정 31 | `rec.delete()` 명명(destroy 아님) · 파라미터 스코프 (M2C) |
589
610
  | 결정 33 | 관계 선언 위치(컬럼 + relations) · 문자열 테이블명 (M2D) |
590
611
  | 결정 34 | 집계·조인 그룹(groupBy·having·distinct·withCount·join) (M2E) |
591
- | 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) |
612
+ | 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) → **결정 90 으로 개정** |
592
613
  | 결정 39 | 마이그레이션 합성형(파일 replay → 스키마 diff · no-auto-drop) |
614
+ | 결정 90 | 벌크 3종 `BulkResult`(count+meta) 전 방언 통일 · updateAll/deleteAll bigint→number (W14) |
593
615
  | 결정 43 | 네이밍 정본화 · DB 네이밍 SSOT(§1.2 · 테이블 snake · 컬럼 camel) |
594
616
  | 결정 46 | doctor 컬럼(column-casing)·모델/페이지 파일명 검사 3종 |
595
617
  | 결정 47 | `gaon g model` 다단어 테이블명 snake_case(마지막 단어 복수) |
@@ -36,6 +36,12 @@ const props = pageProps<'web:posts#index'>()
36
36
  액션 → `'web:posts#index'`.
37
37
  - **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
38
38
  로 import 하지 않는다.
39
+ - **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
40
+ 라우팅**이라 클라이언트 라우터 라이브러리가 없다. `vue-router`·`react-router`
41
+ 를 install/import 하지 말 것(`createRouter`·`useRoute`·`useRouter`·`<RouterLink>`
42
+ 전부 없음 · CLAUDE.md 규칙 12). 페이지 전환은 `gaonjs/vue` 의 `router`
43
+ (`router.visit(url)`·`router.get/post/delete`), 폼은 `useForm(...)` (결정 64).
44
+ 라우트 정의는 서버의 `apps/<앱>/routes.ts` 뿐이다.
39
45
  - **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
40
46
  금지 (라우트를 모른다는 순수 규칙).
41
47
 
@@ -259,6 +265,19 @@ async function runSearch(q: string) {
259
265
  shared-composable-purity 위반. 데이터는 props/인자로.
260
266
  - **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
261
267
  `gaonjs/vue` 의 `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
268
+ - **`vue-router` import 금지** — Gaon 은 클라이언트 라우터가 없다(Inertia =
269
+ SPA + 서버 라우팅). `import { useRouter } from 'vue-router'` 는 존재하지 않는
270
+ 의존을 끌어와 컴파일 실패한다 — 전환은 `gaonjs/vue` 의 `router`, 라우트 정의는
271
+ `apps/<앱>/routes.ts`(§1).
272
+ - **로그아웃은 `router.delete('/session')`** — `?_method=DELETE` 폼 override(Rails/
273
+ Laravel 관례)는 Gaon 에서 안 통한다(Inertia 는 실 DELETE 를 보낸다 · 컴파일은
274
+ 통과해도 런타임 조용히 파손). 로그아웃 링크/버튼은 `router.delete(...)` 또는
275
+ `useForm(...).delete(...)` 로 한다(결정 64 · 절대 규칙 6). `_method` 사용은 doctor
276
+ **method-override** 가 잡는다(결정 89).
277
+ - **채널 구독은 `useChannel()`** — 실시간 클라이언트는 `gaonjs/vue` 의
278
+ `useChannel(name, opts)` 가 정본(결정 87). `new WebSocket` 을 손으로 짜면
279
+ URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클을 재구현하다
280
+ 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에.
262
281
  - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상.
263
282
  - **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
264
283
  Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
@@ -89,29 +89,36 @@ const members = await ctx.presence()
89
89
  - leave 는 best-effort 이고, 서버가 죽으면 허브가 그 서버의 멤버 전원을
90
90
  **즉시** 정리한다 (TCP `close`).
91
91
 
92
- ### 4. 클라이언트 (WebSocket)
92
+ ### 4. 클라이언트 (`useChannel` · 결정 87)
93
93
 
94
- 브라우저는 표준 WebSocket 으로 채널에 접속한다. 세션 앱은 세션 쿠키로,
95
- JWT 앱은 쿼리(`access_token`)로 인증한다.
96
-
97
- **경로는 `<앱 프리픽스>/gaon/ws/<채널명>`** 이고, 보내는 프레임은 `{ t: 'msg',
98
- data }` **봉투** 페이로드를 그대로 보내면 서버가 `onMessage` 로
99
- 흘리지 않는다. 받는 프레임도 같은 모양(`t` 로 종류를 가른다).
94
+ **정본 = `gaonjs/vue` `useChannel(name, opts)`.** 채널 구독의 One Way 다 —
95
+ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{ t:'msg', data }`)
96
+ 감싸기/풀기·마운트 접속·언마운트 정리·반응형 상태를 한 번에 준다. `new WebSocket`
97
+ 손으로 짜지 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
98
+ 자동 인증, JWT 앱은 `params: { access_token }`.
100
99
 
101
100
  ```ts
102
- // 브라우저측채널 구독 래핑은 컴포저블에 (agents/frontend.md)
103
- // web 앱(프리픽스 /)의 room 채널 = /gaon/ws/room. admin 앱이면 /admin/gaon/ws/room.
104
- const ws = new WebSocket(`wss://example.com/gaon/ws/room?room=42`)
105
- ws.onmessage = (ev) => {
106
- const frame = JSON.parse(ev.data) // { t: 'msg' | 'presence' | ... , data }
107
- if (frame.t === 'msg') {
108
- // 서버가 broadcast/send 데이터
109
- }
101
+ // apps/web/composables/useRoom.ts — 컴포저블에 래핑(agents/frontend.md §3.2)
102
+ import { useChannel } from 'gaonjs/vue'
103
+
104
+ export function useRoom(roomId: number) {
105
+ // messages(반응형)·status·send·connect·close 돌려준다. 마운트에 접속.
106
+ const { messages, status, send } = useChannel('room', {
107
+ params: { room: roomId },
108
+ onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
109
+ onPresence: (delta) => { /* 접속자 join/leave */ },
110
+ })
111
+ return { messages, status, send }
110
112
  }
111
- // 보낼 때도 봉투로 감싼다 — 이래야 채널의 onMessage 가 호출된다.
112
- ws.send(JSON.stringify({ t: 'msg', data: { text: '안녕하세요' } }))
113
113
  ```
114
114
 
115
+ - **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
116
+ 정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
117
+ - **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출, `presence` 는
118
+ `onPresence`. 그 외는 `onFrame`.
119
+ - **탈출구** — 표준 WebSocket 이 필요하면 `new WebSocket('<프리픽스>/gaon/ws/<채널명>')`
120
+ 을 직접 쓸 수 있다(봉투·라이프사이클을 스스로 책임진다). 기본 경로는 `useChannel`.
121
+
115
122
  ### 5. 허브 프로세스 (`gaon hub`)
116
123
 
117
124
  허브는 접속자 목록의 단일 권위이자 서버 간 중계다.
@@ -7,12 +7,21 @@
7
7
 
8
8
  ### 1. 보안 기본값 = fail-closed (v0.15 §2.5.1)
9
9
 
10
- **CORS · rate limit · CSRF 코어에서 기본 켬.** 끄는 것은 명시적
11
- 설정으로만 (v0.15 §2.5.1 원문 근거).
10
+ **CORS · rate limit · CSRF · 보안 응답 헤더는 코어에서 기본 켬.** 끄는 것은
11
+ 명시적 설정으로만 (v0.15 §2.5.1 원문 근거).
12
12
 
13
13
  보안 결함 = 보안이 선택 설치면 설치 안 한 앱의 기본값이 무방비가
14
14
  된다. Rails/Laravel 이 증명한 원칙이다 (§2.5.1).
15
15
 
16
+ - **보안 응답 헤더 (결정 86)** — 코어가 모든 응답에 CSP·HSTS(HTTPS 한정)·
17
+ `X-Content-Type-Options: nosniff`·`X-Frame-Options: SAMEORIGIN`·
18
+ `Referrer-Policy: strict-origin-when-cross-origin`·`Cross-Origin-Opener-Policy`
19
+ 를 자동으로 붙인다. 기본 CSP 는 Inertia SPA + vite 스택에서 안 깨지게 튜닝돼
20
+ 있다(`script-src 'self'`·인라인 스타일 허용·`connect-src ... ws: wss:` 로
21
+ realtime 허용). 끄거나 조정은 `createApp({ security: { securityHeaders: … } })`
22
+ — `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
23
+ `helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
24
+
16
25
  ### 2. 세션·CSRF·JWT
17
26
 
18
27
  - 세션은 앱별 완전 분리 (v0.15 §7 · Fastify 캡슐화 스코프): 쿠키
@@ -275,6 +275,11 @@ export default controller({
275
275
  - **`@gaonjs/*` 스코프 직접 import 금지** — 파사드 `gaonjs/*` 만.
276
276
  - **bigint PK 를 render props 로 흘릴 때는 `String(p.id)` 정규화**
277
277
  (결정 37 · 상세는 `agents/frontend.md`).
278
+ - **정적 파일(robots.txt·favicon.ico·이미지 등)은 `apps/<앱>/static/`** 에 둔다
279
+ (결정 85) — 앱 prefix 아래로 서빙된다(web→`/robots.txt`, admin→`/admin/robots.txt`).
280
+ 라우트·`/assets/*` 가 항상 우선하므로 라우트와 같은 경로에 두면 가려진다
281
+ (doctor **static-collision** 가 경고). 정적 서빙에 `@fastify/static` 을 따로
282
+ 깔지 말 것(코어 내장).
278
283
 
279
284
  ## 관련 결정 번호
280
285
 
@@ -0,0 +1,4 @@
1
+ # {{PROJECT_NAME}} · 정적 파일 폴더 (결정 85) — 이 폴더의 파일은 앱 루트로 서빙된다.
2
+ # 예: 이 robots.txt 는 /robots.txt 로, static/img/logo.png 은 /img/logo.png 으로.
3
+ User-agent: *
4
+ Disallow:
@@ -0,0 +1,98 @@
1
+ # {{PROJECT_NAME}} 운영 스택 — 웹·워커·허브 3종 프로세스 + 실 인프라.
2
+ #
3
+ # 개발 스택(docker-compose.yaml)과 달리 앱 이미지(Dockerfile)를 빌드해 serve·work·
4
+ # hub 를 별도 서비스로 띄운다(운영 프로세스 3종 · CLAUDE.md §2 · 결정 60). 인프라는
5
+ # 실 pg·redis·nats(목업 금지 §9). 시크릿은 env 로 주입한다 — 이 파일에 값을 박지 말 것.
6
+ #
7
+ # 기동: docker compose -f compose.prod.yaml up -d --build
8
+ # 정지: docker compose -f compose.prod.yaml down
9
+ name: {{PROJECT_NAME}}-prod
10
+
11
+ x-app-env: &app-env
12
+ DATABASE_URL: postgres://{{PROJECT_NAME}}:${DB_PASSWORD:?set DB_PASSWORD}@postgres:5432/{{PROJECT_NAME}}
13
+ REDIS_URL: redis://redis:6379
14
+ NATS_URL: nats://nats:4222
15
+ COOKIE_SECRET: ${COOKIE_SECRET:?set COOKIE_SECRET (32+ chars)}
16
+ NODE_ENV: production
17
+
18
+ services:
19
+ # 웹 프로세스(gaon serve). node:cluster 워커 수는 WEB_CONCURRENCY 로(기본 1).
20
+ web:
21
+ build: .
22
+ command: ["pnpm", "serve"]
23
+ environment:
24
+ <<: *app-env
25
+ PORT: "3000"
26
+ WEB_CONCURRENCY: "${WEB_CONCURRENCY:-1}"
27
+ ports:
28
+ - "3000:3000"
29
+ depends_on:
30
+ postgres: { condition: service_healthy }
31
+ redis: { condition: service_healthy }
32
+ nats: { condition: service_healthy }
33
+ restart: unless-stopped
34
+
35
+ # 워커 프로세스(gaon work) — 잡·리스너·스케줄러·아웃박스 릴레이.
36
+ worker:
37
+ build: .
38
+ command: ["pnpm", "work"]
39
+ environment: *app-env
40
+ depends_on:
41
+ postgres: { condition: service_healthy }
42
+ nats: { condition: service_healthy }
43
+ restart: unless-stopped
44
+
45
+ # 허브 프로세스(gaon hub) — 리더만 bind, 프레즌스 단일 권위(결정 60 · §7).
46
+ hub:
47
+ build: .
48
+ command: ["pnpm", "hub"]
49
+ environment:
50
+ <<: *app-env
51
+ GAON_HUB_PORT: "4001"
52
+ GAON_HUB_ADVERTISE: "hub:4001"
53
+ depends_on:
54
+ nats: { condition: service_healthy }
55
+ restart: unless-stopped
56
+
57
+ postgres:
58
+ image: postgres:16-alpine
59
+ environment:
60
+ POSTGRES_USER: {{PROJECT_NAME}}
61
+ POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
62
+ POSTGRES_DB: {{PROJECT_NAME}}
63
+ volumes:
64
+ - pgdata:/var/lib/postgresql/data
65
+ healthcheck:
66
+ test: ["CMD-SHELL", "pg_isready -U {{PROJECT_NAME}} -d {{PROJECT_NAME}}"]
67
+ interval: 5s
68
+ timeout: 3s
69
+ retries: 30
70
+ restart: unless-stopped
71
+
72
+ redis:
73
+ image: redis:7-alpine
74
+ volumes:
75
+ - redisdata:/data
76
+ healthcheck:
77
+ test: ["CMD", "redis-cli", "ping"]
78
+ interval: 5s
79
+ timeout: 3s
80
+ retries: 30
81
+ restart: unless-stopped
82
+
83
+ nats:
84
+ image: nats:2.10-alpine
85
+ command: ["-js", "-sd", "/data", "-m", "8222"]
86
+ volumes:
87
+ - natsdata:/data
88
+ healthcheck:
89
+ test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8222/healthz"]
90
+ interval: 5s
91
+ timeout: 3s
92
+ retries: 30
93
+ restart: unless-stopped
94
+
95
+ volumes:
96
+ pgdata:
97
+ redisdata:
98
+ natsdata:
@@ -3,6 +3,7 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
+ "packageManager": "pnpm@10.27.0",
6
7
  "engines": {
7
8
  "node": ">=22"
8
9
  },
package/dist/work.js CHANGED
@@ -44,6 +44,8 @@ function emitHuman(e) {
44
44
  return undefined;
45
45
  case 'relay':
46
46
  return ` ↪ 아웃박스 릴레이 — ${e.count}건 발행`;
47
+ case 'purge':
48
+ return ` 🧹 아웃박스 정리 — ${e.count}건 삭제(보존 기간 초과)`;
47
49
  default:
48
50
  return undefined;
49
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.18.0",
3
+ "version": "0.21.2",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.5.0",
30
+ "@gaonjs/async": "0.6.0",
31
+ "@gaonjs/config": "0.5.1",
32
+ "@gaonjs/data": "0.9.0",
31
33
  "@gaonjs/core": "0.2.0",
32
- "@gaonjs/config": "0.4.1",
33
- "@gaonjs/web": "0.6.1",
34
- "@gaonjs/mail": "0.1.1",
35
- "@gaonjs/data": "0.8.4"
34
+ "@gaonjs/mail": "0.1.2",
35
+ "@gaonjs/web": "0.7.0"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""