@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.
- package/dist/doctor/method-override.d.ts +5 -0
- package/dist/doctor/method-override.js +75 -0
- package/dist/doctor/route-registration.d.ts +5 -0
- package/dist/doctor/route-registration.js +77 -0
- package/dist/doctor/static-collision.d.ts +5 -0
- package/dist/doctor/static-collision.js +84 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +1 -1
- package/dist/doctor.js +14 -2
- package/dist/index.js +8 -3
- package/dist/serve.d.ts +10 -0
- package/dist/serve.js +90 -2
- package/dist/templates/project/.dockerignore.tpl +12 -0
- package/dist/templates/project/AGENTS.md.tpl +9 -5
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/Dockerfile.tpl +30 -0
- package/dist/templates/project/agents/async.md.tpl +4 -0
- package/dist/templates/project/agents/data.md.tpl +37 -15
- package/dist/templates/project/agents/frontend.md.tpl +19 -0
- package/dist/templates/project/agents/realtime.md.tpl +24 -17
- package/dist/templates/project/agents/security.md.tpl +11 -2
- package/dist/templates/project/agents/web.md.tpl +5 -0
- package/dist/templates/project/apps/web/static/robots.txt.tpl +4 -0
- package/dist/templates/project/compose.prod.yaml.tpl +98 -0
- package/dist/templates/project/package.json.tpl +1 -0
- package/dist/work.js +2 -0
- package/package.json +6 -6
|
@@ -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
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/doctor/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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
|
-
*
|
|
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) =
|
|
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 정적 검사 (
|
|
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 ·
|
|
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
|
-
* 워커
|
|
12
|
-
* 1
|
|
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
|
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
이 문서는 **AI 코딩 에이전트**(Claude · Codex · Cursor · Copilot 등)와
|
|
4
4
|
사람 개발자가 Gaon 프로젝트에서 작업할 때 참조하는 관례의 진입점이다.
|
|
5
|
-
정본은 설계 문서(
|
|
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` 검사
|
|
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 # 정적 검사
|
|
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.
|
|
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 # 정적 검사
|
|
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<
|
|
197
|
-
| `deleteAll` | `()` | `Promise<
|
|
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<
|
|
222
|
-
| `insertOrIgnore` | `(row \| rows)` | `Promise<
|
|
223
|
-
| `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<
|
|
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
|
-
> 벌크
|
|
228
|
-
>
|
|
229
|
-
>
|
|
230
|
-
>
|
|
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() //
|
|
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) //
|
|
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. 클라이언트 (
|
|
92
|
+
### 4. 클라이언트 (`useChannel` · 결정 87)
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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,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:
|
package/dist/work.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "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.
|
|
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/
|
|
33
|
-
"@gaonjs/web": "0.
|
|
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})\""
|