@gaonjs/cli 0.31.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/build.d.ts +11 -0
- package/dist/commands/build.js +49 -0
- package/dist/commands/check.js +41 -0
- package/dist/commands/db.d.ts +5 -1
- package/dist/commands/db.js +92 -36
- package/dist/db/resolve.d.ts +4 -6
- package/dist/db/resolve.js +26 -13
- package/dist/dev/build.d.ts +34 -0
- package/dist/dev/build.js +144 -0
- package/dist/dev/frontend-build.d.ts +3 -3
- package/dist/dev/frontend-build.js +42 -37
- package/dist/doctor/async-offload.d.ts +3 -1
- package/dist/doctor/async-offload.js +5 -2
- package/dist/doctor/auth-wiring.d.ts +4 -2
- package/dist/doctor/auth-wiring.js +13 -6
- package/dist/doctor/connections.d.ts +23 -1
- package/dist/doctor/connections.js +85 -14
- package/dist/doctor/csrf-wiring.d.ts +3 -1
- package/dist/doctor/csrf-wiring.js +9 -5
- package/dist/doctor/schema-relations.d.ts +7 -0
- package/dist/doctor/schema-relations.js +114 -0
- package/dist/doctor/seal-security.js +12 -6
- package/dist/doctor/source-scan.d.ts +11 -0
- package/dist/doctor/source-scan.js +70 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +1 -1
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +8 -2
- package/dist/generate.d.ts +1 -1
- package/dist/generate.js +55 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +22 -4
- package/dist/scaffold/app.js +2 -1
- package/dist/templates/auth/Dashboard.vue.tpl +2 -2
- package/dist/templates/auth/Login.vue.tpl +2 -2
- package/dist/templates/auth/Signup.vue.tpl +2 -2
- package/dist/templates/auth/app.config.ts.tpl +3 -3
- package/dist/templates/auth/dashboard.controller.ts.tpl +1 -1
- package/dist/templates/auth/registration.controller.ts.tpl +3 -3
- package/dist/templates/auth/session.controller.ts.tpl +5 -5
- package/dist/templates/project/AGENTS.md.tpl +12 -6
- package/dist/templates/project/agents/async.md.tpl +26 -5
- package/dist/templates/project/agents/data.md.tpl +56 -12
- package/dist/templates/project/agents/security.md.tpl +16 -2
- package/dist/templates/project/agents/storage.md.tpl +110 -0
- package/dist/templates/project/agents/testing.md.tpl +24 -5
- package/dist/templates/project/docker-compose.yaml.tpl +7 -5
- package/dist/templates/project/gaon.config.ts.tpl +4 -0
- package/dist/templates/project/package.json.tpl +1 -1
- package/dist/templates/project/test/setup.ts.tpl +10 -7
- package/dist/templates/project/vite.config.ts.tpl +6 -5
- package/dist/templates/project/vitest.config.ts.tpl +5 -0
- package/package.json +7 -7
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' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security';
|
|
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' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations';
|
|
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
|
+
// 25 검사(전체 목록은 AGENTS §2.2 · doctor.ts `ALL_RULES`)가 모두 이 DoctorCheck
|
|
4
4
|
// 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
|
|
5
5
|
// warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
|
|
6
6
|
// errors.length > 0 이면 fail 로 판단한다.
|
package/dist/doctor.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ export type { ResponseKind, ActionUsage } from './doctor/response-mixing.js';
|
|
|
4
4
|
export { inspectControllerSource, checkResponseMixing } from './doctor/response-mixing.js';
|
|
5
5
|
export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one.js';
|
|
6
6
|
export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
7
|
-
export { extractConfigDbKeys, extractKeyUses, checkConnections } from './doctor/connections.js';
|
|
7
|
+
export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
|
|
8
|
+
export { checkSchemaRelations } from './doctor/schema-relations.js';
|
|
8
9
|
export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
|
|
9
10
|
export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
|
|
10
11
|
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
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
|
+
* 25 검사를 조립한다:
|
|
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 규칙)
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
|
|
26
26
|
* 22) page-layout-breakpoint (결정 107 · 페이지 레이아웃 브레이크포인트 직접 사용 = 안내 경고)
|
|
27
27
|
* 23) link-button-nesting (결정 113 · Link 로 Button 감싸기 = <a><button> 중첩 경고)
|
|
28
|
+
* 24) seal-security (결정 121 · seal 클라 배선 · 보안 역전)
|
|
29
|
+
* 25) schema-relations (§4.5 · 결정 134 · 커넥션 가로지르는 belongsTo·관계 · 대상 부재 error)
|
|
28
30
|
*
|
|
29
31
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
30
32
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -41,6 +43,7 @@ import { checkResponseMixing } from './doctor/response-mixing.js';
|
|
|
41
43
|
import { checkNPlusOne } from './doctor/n-plus-one.js';
|
|
42
44
|
import { checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
43
45
|
import { checkConnections } from './doctor/connections.js';
|
|
46
|
+
import { checkSchemaRelations } from './doctor/schema-relations.js';
|
|
44
47
|
import { checkMigrationDiff } from './doctor/migration-diff.js';
|
|
45
48
|
import { checkSharedComposablePurity } from './doctor/shared-composable-purity.js';
|
|
46
49
|
import { checkNoAutoImport } from './doctor/no-auto-import.js';
|
|
@@ -68,7 +71,8 @@ import { FIXERS, FIXER_CAPABILITIES } from './doctor/fixers/index.js';
|
|
|
68
71
|
export { inspectControllerSource, checkResponseMixing } from './doctor/response-mixing.js';
|
|
69
72
|
export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one.js';
|
|
70
73
|
export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
71
|
-
export { extractConfigDbKeys, extractKeyUses, checkConnections } from './doctor/connections.js';
|
|
74
|
+
export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
|
|
75
|
+
export { checkSchemaRelations } from './doctor/schema-relations.js';
|
|
72
76
|
export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
|
|
73
77
|
export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
|
|
74
78
|
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
|
@@ -115,6 +119,7 @@ const ALL_RULES = [
|
|
|
115
119
|
'page-layout-breakpoint',
|
|
116
120
|
'link-button-nesting',
|
|
117
121
|
'seal-security',
|
|
122
|
+
'schema-relations',
|
|
118
123
|
];
|
|
119
124
|
const CHECKERS = {
|
|
120
125
|
'response-mixing': checkResponseMixing,
|
|
@@ -141,6 +146,7 @@ const CHECKERS = {
|
|
|
141
146
|
'page-layout-breakpoint': checkPageLayoutBreakpoint,
|
|
142
147
|
'link-button-nesting': checkLinkButtonNesting,
|
|
143
148
|
'seal-security': checkSealSecurity,
|
|
149
|
+
'schema-relations': checkSchemaRelations,
|
|
144
150
|
};
|
|
145
151
|
/**
|
|
146
152
|
* 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
|
package/dist/generate.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare function patchRoutes(existing: string): string | null;
|
|
|
28
28
|
* · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
|
|
29
29
|
* 성공 시 loadUser import 도 함께 보장한다.
|
|
30
30
|
*/
|
|
31
|
-
export declare function patchAppConfigAddAuth(existing: string): string | null;
|
|
31
|
+
export declare function patchAppConfigAddAuth(existing: string, app?: string): string | null;
|
|
32
32
|
/** 스캐폴드를 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip 한다. routes.ts 는 패치. */
|
|
33
33
|
export declare function writeAuthScaffold(cwd: string, opts?: AuthScaffoldOptions): ScaffoldResult;
|
|
34
34
|
export interface GenerateAuthOptions {
|
package/dist/generate.js
CHANGED
|
@@ -19,11 +19,38 @@ import { authUiKitFiles, writeUiKitFiles } from './uikit.js';
|
|
|
19
19
|
// ── 템플릿 로드·치환 ───────────────────────────────────────────
|
|
20
20
|
// 템플릿은 이 모듈과 같은 위치의 templates/auth/ 에 있다(빌드가 dist 로 복사).
|
|
21
21
|
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'auth');
|
|
22
|
-
/**
|
|
23
|
-
*
|
|
22
|
+
/**
|
|
23
|
+
* 앱의 URL 프리픽스 — web 은 루트라 '' (URL 앞에 아무것도 안 붙음), 그 외는 '/<app>'
|
|
24
|
+
* (결정 141 · 12차 W4). 폴더명=프리픽스 관례(§3.3)와 정합. 라우트 **등록** 경로는
|
|
25
|
+
* 프레임웍이 앱 스코프에서 자동으로 프리픽스를 붙이므로 이 값은 라우트 등록이 아니라
|
|
26
|
+
* **절대 URL**(form.post·router.delete·Link href·redirect·loginRedirect)에만 쓴다.
|
|
27
|
+
*/
|
|
28
|
+
function urlPrefixFor(app) {
|
|
29
|
+
return app === 'web' ? '' : `/${app}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 앱의 세션 secret 환경변수명 — web 은 'SESSION_SECRET'(기존), 그 외는
|
|
33
|
+
* '<APP>_SESSION_SECRET'(결정 141 · 앱별 세션 완전 분리 · session.ts 는 앱마다
|
|
34
|
+
* secret 이 달라야 분리가 완성된다고 명시). 앱 이름의 비영숫자는 '_' 로 정규화.
|
|
35
|
+
*/
|
|
36
|
+
function sessionSecretEnvFor(app) {
|
|
37
|
+
if (app === 'web')
|
|
38
|
+
return 'SESSION_SECRET';
|
|
39
|
+
return `${app.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_SESSION_SECRET`;
|
|
40
|
+
}
|
|
41
|
+
/** 앱별 개발용 세션 secret 폴백(32자 이상 · 앱마다 다르게). */
|
|
42
|
+
function devSessionSecretFor(app) {
|
|
43
|
+
return `dev-only-session-secret-${app}-change-me-now!!`;
|
|
44
|
+
}
|
|
45
|
+
/** 템플릿 파일을 읽어 토큰을 치환한다. {{APP_NAME}}·{{URL_PREFIX}}·
|
|
46
|
+
* {{SESSION_SECRET_ENV}} 는 Vue 의 {{ }} 보간과 겹치지 않는 고정 리터럴이라
|
|
47
|
+
* 단순 replaceAll 로 안전하다(결정 141). */
|
|
24
48
|
function renderTemplate(name, app) {
|
|
25
49
|
const raw = readFileSync(join(TEMPLATE_DIR, name), 'utf8');
|
|
26
|
-
return raw
|
|
50
|
+
return raw
|
|
51
|
+
.replaceAll('{{APP_NAME}}', app)
|
|
52
|
+
.replaceAll('{{URL_PREFIX}}', urlPrefixFor(app))
|
|
53
|
+
.replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app));
|
|
27
54
|
}
|
|
28
55
|
/** 템플릿 파일 → 생성 경로 매핑(라우트 제외 — 라우트는 패치로 처리). */
|
|
29
56
|
const TEMPLATES = [
|
|
@@ -76,18 +103,30 @@ export function patchRoutes(existing) {
|
|
|
76
103
|
* · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
|
|
77
104
|
* 성공 시 loadUser import 도 함께 보장한다.
|
|
78
105
|
*/
|
|
79
|
-
export function patchAppConfigAddAuth(existing) {
|
|
106
|
+
export function patchAppConfigAddAuth(existing, app = 'web') {
|
|
80
107
|
if (/\bauth\s*:/.test(existing))
|
|
81
108
|
return null;
|
|
82
109
|
const m = existing.match(/defineAppConfig\(\s*\{/);
|
|
83
110
|
if (!m || m.index === undefined)
|
|
84
111
|
return null;
|
|
85
112
|
const insertAt = m.index + m[0].length;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
const prefix = urlPrefixFor(app);
|
|
114
|
+
// 결정 141(12차 W4): 대상 앱에 세션이 없으면(예: gaon g app 이 만든 빈 config)
|
|
115
|
+
// 세션도 함께 배선한다 — 세션이 없으면 로그인이 저장될 곳이 없어 왕복이 깨지고,
|
|
116
|
+
// doctor 가 세션/CSRF 누락을 error 로 잡는다(결정 140). secret 은 앱별로 분리한다.
|
|
117
|
+
const hasSession = /\bsession\s*:/.test(existing);
|
|
118
|
+
const lines = [];
|
|
119
|
+
if (!hasSession) {
|
|
120
|
+
const secretEnv = sessionSecretEnvFor(app);
|
|
121
|
+
lines.push(` // 세션 secret 은 32자 이상 — .env 의 ${secretEnv} 로 주입한다(앱별 세션 완전 분리).`, ` session: { secret: process.env.${secretEnv} ?? '${devSessionSecretFor(app)}' },`);
|
|
122
|
+
}
|
|
123
|
+
lines.push(' // 결정 59: 세션 userId → 사용자 로드 배선(g auth). 이게 있어야 로그인 후', ' // this.currentUser / this.requireAuth() 가 실제 사용자를 받는다.', ` auth: { loadUser, loginRedirect: '${prefix}/session/new' },`);
|
|
124
|
+
// 결정 141: 닫는 괄호 오염 방지 — 빈 `defineAppConfig({})` 처럼 삽입 지점 바로 뒤가
|
|
125
|
+
// 닫는 `}` 면 삽입 블록 끝에 개행을 더해 `})` 를 제 줄로 내린다(`},})` 방지).
|
|
126
|
+
const rest = existing.slice(insertAt);
|
|
127
|
+
const closesImmediately = /^\s*\}/.test(rest);
|
|
128
|
+
const block = '\n' + lines.join('\n') + (closesImmediately ? '\n' : '');
|
|
129
|
+
let out = existing.slice(0, insertAt) + block + rest;
|
|
91
130
|
// loadUser import 보장 — gaonjs/config import 바로 뒤에 넣는다.
|
|
92
131
|
if (!/from\s+['"]\.\/auth\.js['"]/.test(out)) {
|
|
93
132
|
const im = out.match(/import\s+\{[^}]*\}\s+from\s+['"]gaonjs\/config['"][^\n]*\n/);
|
|
@@ -124,7 +163,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
|
|
|
124
163
|
// 결정 59: auth 배선이 없으면 로그인 후에도 currentUser 가 영구 null 이다.
|
|
125
164
|
if (file.path === `apps/${app}/app.config.ts`) {
|
|
126
165
|
const existing = readFileSync(abs, 'utf8');
|
|
127
|
-
const patchedConfig = patchAppConfigAddAuth(existing);
|
|
166
|
+
const patchedConfig = patchAppConfigAddAuth(existing, app);
|
|
128
167
|
if (patchedConfig) {
|
|
129
168
|
writeFileSync(abs, patchedConfig, 'utf8');
|
|
130
169
|
patched.push(file.path);
|
|
@@ -134,7 +173,8 @@ export function writeAuthScaffold(cwd, opts = {}) {
|
|
|
134
173
|
skipped.push(file.path);
|
|
135
174
|
warnings.push(`apps/${app}/app.config.ts 가 이미 있어 인증 배선을 자동 추가하지 못했습니다.\n` +
|
|
136
175
|
`→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
|
|
137
|
-
`
|
|
176
|
+
` session: { secret: process.env.${sessionSecretEnvFor(app)} ?? '32자 이상 비밀' },\n` +
|
|
177
|
+
` auth: { loadUser, loginRedirect: '${urlPrefixFor(app)}/session/new' } // import { loadUser } from './auth.js'\n` +
|
|
138
178
|
`→ 이 배선이 없으면 로그인해도 this.currentUser/requireAuth() 가 사용자를 받지 못합니다.`);
|
|
139
179
|
}
|
|
140
180
|
else {
|
|
@@ -191,11 +231,13 @@ export function runGenerateAuthCommand(opts = {}) {
|
|
|
191
231
|
for (const w of result.warnings)
|
|
192
232
|
lines.push('', ` ⚠ ${w.split('\n').join('\n ')}`);
|
|
193
233
|
lines.push('');
|
|
234
|
+
const prefix = urlPrefixFor(app);
|
|
235
|
+
const secretEnv = sessionSecretEnvFor(app);
|
|
194
236
|
lines.push(' 다음 단계:');
|
|
195
|
-
lines.push(
|
|
237
|
+
lines.push(` 1) .env 에 REDIS_URL·${secretEnv}(32자 이상)·COOKIE_SECRET 을 설정한다.`);
|
|
196
238
|
lines.push(' 2) gaon db diff && gaon db migrate 로 users 테이블을 만든다.');
|
|
197
239
|
lines.push(' 3) gaon dev 로 실행한다 (.gaon 타입 브리지 생성 + 세션·인증 자동 배선).');
|
|
198
|
-
lines.push(
|
|
240
|
+
lines.push(` → ${prefix}/registration/new 회원가입 · ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
|
|
199
241
|
lines.push('');
|
|
200
242
|
process.stdout.write(lines.join('\n') + '\n');
|
|
201
243
|
return 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
|
|
|
4
4
|
export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, type DevConsole, type DevConsoleOptions, type DevSource, type DevLevel, type ComposeStatus, type ComposeUpOptions, type EnsureInfraResult, type DockerLocateOptions, type TscWatcherOptions, type TscWatcherHandle, type RestartWatcherOptions, type RestartWatcherHandle, } from "./dev/index.js";
|
|
5
5
|
export { runCheckCommand, type CheckCommandOptions, type CheckStep, type CheckStepStatus, type CheckStepResult, } from "./commands/check.js";
|
|
6
6
|
export { runGenCommand, regenerateProjectGaon, type GenCommandOptions, type GenResult, } from "./commands/gen.js";
|
|
7
|
+
export { runBuildCommand, type BuildCommandOptions, type BuildCommandResult, } from "./commands/build.js";
|
|
8
|
+
export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, type BuildAppOptions, type DistVerifyResult, } from "./dev/build.js";
|
|
7
9
|
export { runNewCommand, type NewCommandOptions, type NewCommandResult } from "./commands/new.js";
|
|
8
10
|
export { runConsoleCommand, type ConsoleCommandOptions } from "./commands/console.js";
|
|
9
11
|
export { runTestCommand, type TestCommandOptions, type TestScope } from "./commands/test.js";
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { MILESTONES, VERSION, HOMEPAGE, loadDotEnv } from "@gaonjs/core";
|
|
|
14
14
|
import { runDevCommand } from "./commands/dev.js";
|
|
15
15
|
import { runCheckCommand } from "./commands/check.js";
|
|
16
16
|
import { runGenCommand } from "./commands/gen.js";
|
|
17
|
+
import { runBuildCommand } from "./commands/build.js";
|
|
17
18
|
import { runNewCommand } from "./commands/new.js";
|
|
18
19
|
import { runConsoleCommand } from "./commands/console.js";
|
|
19
20
|
import { runTestCommand } from "./commands/test.js";
|
|
@@ -32,6 +33,8 @@ export { runDevCommand } from "./commands/dev.js";
|
|
|
32
33
|
export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, } from "./dev/index.js";
|
|
33
34
|
export { runCheckCommand, } from "./commands/check.js";
|
|
34
35
|
export { runGenCommand, regenerateProjectGaon, } from "./commands/gen.js";
|
|
36
|
+
export { runBuildCommand, } from "./commands/build.js";
|
|
37
|
+
export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, } from "./dev/build.js";
|
|
35
38
|
export { runNewCommand } from "./commands/new.js";
|
|
36
39
|
export { runConsoleCommand } from "./commands/console.js";
|
|
37
40
|
export { runTestCommand } from "./commands/test.js";
|
|
@@ -102,9 +105,10 @@ function renderHelp(version = VERSION) {
|
|
|
102
105
|
" gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
|
|
103
106
|
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
104
107
|
" gaon gen .gaon 타입 브리지 + api() 런타임 매니페스트만 재생성 (서버·검사 없이 · build 전제 · --json)",
|
|
108
|
+
" gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
|
|
105
109
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
106
110
|
" gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
|
|
107
|
-
" gaon doctor 정적 검사 (
|
|
111
|
+
" gaon doctor 정적 검사 (25 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계)",
|
|
108
112
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
109
113
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
110
114
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -121,8 +125,8 @@ function renderHelp(version = VERSION) {
|
|
|
121
125
|
" gaon work 워커 프로세스 (잡·리스너·스케줄·아웃박스 · graceful drain)",
|
|
122
126
|
" gaon jobs list --failed DLQ(실패 잡) 목록",
|
|
123
127
|
" gaon jobs retry <id> DLQ 잡 재적재",
|
|
124
|
-
" gaon db diff 스키마 ↔ DB 차이 미리보기 (적용 X ·
|
|
125
|
-
" gaon db migrate db/migrations/*.ts replay + 스키마 diff 적용 + 이력",
|
|
128
|
+
" gaon db diff 스키마 ↔ DB 차이 미리보기 (적용 X · 전 커넥션 순회 · --db <키> 로 단일)",
|
|
129
|
+
" gaon db migrate db/migrations/*.ts replay + 스키마 diff 적용 + 이력 (전 커넥션 순회 · --db <키> 로 단일)",
|
|
126
130
|
" gaon db migrate down 가장 최근 이력 한 건 롤백",
|
|
127
131
|
" gaon db migrate --dry-run 적용 없이 실행 예정 파일 + up SQL 만 출력",
|
|
128
132
|
" gaon db status 마이그레이션 파일 적용/대기 + 스키마 drift",
|
|
@@ -258,6 +262,20 @@ export function runCli(argv, opts = {}) {
|
|
|
258
262
|
});
|
|
259
263
|
return;
|
|
260
264
|
}
|
|
265
|
+
// `gaon build` — 멀티 앱 프론트 프로덕션 빌드(결정 146). gaon gen 뒤 apps/* 를
|
|
266
|
+
// 순회해 앱마다 root/outDir/base 로 vite build. 스캐폴드 build 스크립트가 이걸 부른다.
|
|
267
|
+
if (argv[0] === "build") {
|
|
268
|
+
void runBuildCommand({ json: argv.includes("--json") })
|
|
269
|
+
.then((code) => {
|
|
270
|
+
process.exitCode = code;
|
|
271
|
+
})
|
|
272
|
+
.catch((err) => {
|
|
273
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
274
|
+
process.stderr.write(` ✗ gaon build 실패: ${msg}\n`);
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
261
279
|
// `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
|
|
262
280
|
// 선택 실행, --json 은 자동화 파싱용.
|
|
263
281
|
// exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
|
|
@@ -344,7 +362,7 @@ export function runCli(argv, opts = {}) {
|
|
|
344
362
|
if (!sub || !known.includes(sub)) {
|
|
345
363
|
process.stderr.write(` ✗ 알 수 없는 db 서브커맨드: ${sub ?? "(없음)"}\n` +
|
|
346
364
|
` → 지원: gaon db diff | migrate | reset | seed | status\n` +
|
|
347
|
-
` → 옵션: --json · --db <키> · --config <path> · --yes · --dry-run\n`);
|
|
365
|
+
` → 옵션: --json · --db <키>(생략 = 전 커넥션 순회) · --config <path> · --yes · --dry-run\n`);
|
|
348
366
|
process.exitCode = 1;
|
|
349
367
|
return;
|
|
350
368
|
}
|
package/dist/scaffold/app.js
CHANGED
|
@@ -72,9 +72,10 @@ export function appScaffoldFiles(name) {
|
|
|
72
72
|
contents: [
|
|
73
73
|
`// ${name} 앱 설정 — apps/${name}/app.config.ts.`,
|
|
74
74
|
`// 잘 지은 앱의 app.config.ts 는 거의 비어 있다(v0.16 §1.1 ②).`,
|
|
75
|
+
`// 세션·인증 배선은 \`gaon g auth --app ${name}\` 이 여기에 채운다 — 직접 손대지 않는다.`,
|
|
76
|
+
`// 그 전에 상태 변경(POST 등) 라우트를 두면 doctor 가 세션/CSRF 누락을 잡는다(결정 140).`,
|
|
75
77
|
`// 관례에서 벗어날 때만 값을 채운다:`,
|
|
76
78
|
`// hosts: ['${name}.example.com'] // 도메인 바인딩(개발은 ${name}.localhost 자동)`,
|
|
77
|
-
`// session: { cookie: '${name}_session' } // 세션 옵션 덮어쓰기`,
|
|
78
79
|
`import { defineAppConfig } from 'gaonjs/config'`,
|
|
79
80
|
``,
|
|
80
81
|
`export default defineAppConfig({})`,
|
|
@@ -13,10 +13,10 @@ import Button from '@shared/components/ui/Button.vue'
|
|
|
13
13
|
// 이 페이지는 requireAuth 로 보호되므로 런타임엔 항상 로그인 상태(타입은 nullable).
|
|
14
14
|
const shared = useShared()
|
|
15
15
|
|
|
16
|
-
// 로그아웃 = DELETE /session (r.resource('session') 의 destroy).
|
|
16
|
+
// 로그아웃 = DELETE {{URL_PREFIX}}/session (r.resource('session') 의 destroy).
|
|
17
17
|
// HTML <form> 은 DELETE 를 못 보내므로 Inertia 라우터로 실제 메서드를 보낸다(결정 64).
|
|
18
18
|
function logout(): void {
|
|
19
|
-
router.delete('/session', { headers: { 'x-csrf-token': shared.csrf } })
|
|
19
|
+
router.delete('{{URL_PREFIX}}/session', { headers: { 'x-csrf-token': shared.csrf } })
|
|
20
20
|
}
|
|
21
21
|
</script>
|
|
22
22
|
|
|
@@ -36,7 +36,7 @@ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
|
|
|
36
36
|
<Alert v-if="props.error" variant="destructive" class="mb-4">
|
|
37
37
|
<AlertDescription>{{ props.error }}</AlertDescription>
|
|
38
38
|
</Alert>
|
|
39
|
-
<Form @submit="form.post('/session')">
|
|
39
|
+
<Form @submit="form.post('{{URL_PREFIX}}/session')">
|
|
40
40
|
<FormField label="이메일" :error="form.errors.email">
|
|
41
41
|
<Input v-model="form.email" type="email" required />
|
|
42
42
|
</FormField>
|
|
@@ -47,7 +47,7 @@ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
|
|
|
47
47
|
</Form>
|
|
48
48
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
49
49
|
계정이 없으신가요?
|
|
50
|
-
<Link href="/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>
|
|
50
|
+
<Link href="{{URL_PREFIX}}/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>
|
|
51
51
|
</p>
|
|
52
52
|
</CardContent>
|
|
53
53
|
</Card>
|
|
@@ -33,7 +33,7 @@ const form = useForm({ name: '', email: '', password: '', _csrf: shared.csrf })
|
|
|
33
33
|
<Alert v-if="props.error" variant="destructive" class="mb-4">
|
|
34
34
|
<AlertDescription>{{ props.error }}</AlertDescription>
|
|
35
35
|
</Alert>
|
|
36
|
-
<Form @submit="form.post('/registration')">
|
|
36
|
+
<Form @submit="form.post('{{URL_PREFIX}}/registration')">
|
|
37
37
|
<FormField label="이름" :error="form.errors.name">
|
|
38
38
|
<Input v-model="form.name" required />
|
|
39
39
|
</FormField>
|
|
@@ -47,7 +47,7 @@ const form = useForm({ name: '', email: '', password: '', _csrf: shared.csrf })
|
|
|
47
47
|
</Form>
|
|
48
48
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
49
49
|
이미 계정이 있으신가요?
|
|
50
|
-
<Link href="/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</Link>
|
|
50
|
+
<Link href="{{URL_PREFIX}}/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</Link>
|
|
51
51
|
</p>
|
|
52
52
|
</CardContent>
|
|
53
53
|
</Card>
|
|
@@ -3,9 +3,9 @@ import { defineAppConfig } from 'gaonjs/config'
|
|
|
3
3
|
import { loadUser } from './auth.js'
|
|
4
4
|
|
|
5
5
|
export default defineAppConfig({
|
|
6
|
-
// 세션 secret 은 32자 이상 — .env 의
|
|
7
|
-
session: { secret: process.env.
|
|
6
|
+
// 세션 secret 은 32자 이상 — .env 의 {{SESSION_SECRET_ENV}} 로 주입한다(앱별 세션 완전 분리).
|
|
7
|
+
session: { secret: process.env.{{SESSION_SECRET_ENV}} ?? 'dev-only-session-secret-{{APP_NAME}}-change-me!!' },
|
|
8
8
|
// 결정 59: 세션 userId → 사용자 로드 배선. 이게 있어야 로그인 후
|
|
9
9
|
// this.currentUser / this.requireAuth() 가 실제 사용자를 받는다.
|
|
10
|
-
auth: { loadUser, loginRedirect: '/session/new' },
|
|
10
|
+
auth: { loadUser, loginRedirect: '{{URL_PREFIX}}/session/new' },
|
|
11
11
|
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { controller } from 'gaonjs/web'
|
|
3
3
|
|
|
4
4
|
export default controller({
|
|
5
|
-
// GET /dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
|
|
5
|
+
// GET {{URL_PREFIX}}/dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
|
|
6
6
|
async show() {
|
|
7
7
|
// 보호만 하고 페이지 데이터는 넘기지 않는다 — 사용자·csrf 는 자동 주입되고
|
|
8
8
|
// (결정 116), 페이지는 useShared() 로 읽는다. requireAuth 로 게이트만 건다.
|
|
@@ -3,11 +3,11 @@ import { controller, hashPassword } from 'gaonjs/web'
|
|
|
3
3
|
import { User } from '../../../domain/models/User.js'
|
|
4
4
|
|
|
5
5
|
export default controller({
|
|
6
|
-
// GET /registration/new — 회원가입 폼. csrf 는 자동 주입된다(결정 116).
|
|
6
|
+
// GET {{URL_PREFIX}}/registration/new — 회원가입 폼. csrf 는 자동 주입된다(결정 116).
|
|
7
7
|
async new() {
|
|
8
8
|
return this.render('Auth/Signup', { error: null as string | null })
|
|
9
9
|
},
|
|
10
|
-
// POST /registration — 회원가입
|
|
10
|
+
// POST {{URL_PREFIX}}/registration — 회원가입
|
|
11
11
|
async create() {
|
|
12
12
|
const { name, email, password } = this.params({
|
|
13
13
|
_row: {} as { name: string; email: string; password: string },
|
|
@@ -15,6 +15,6 @@ export default controller({
|
|
|
15
15
|
const passwordDigest = await hashPassword(password)
|
|
16
16
|
const user = await User.create({ name, email, passwordDigest })
|
|
17
17
|
this.auth.login(user)
|
|
18
|
-
return this.redirect('/dashboard')
|
|
18
|
+
return this.redirect('{{URL_PREFIX}}/dashboard')
|
|
19
19
|
},
|
|
20
20
|
})
|
|
@@ -3,26 +3,26 @@ import { controller, verifyPassword } from 'gaonjs/web'
|
|
|
3
3
|
import { User } from '../../../domain/models/User.js'
|
|
4
4
|
|
|
5
5
|
export default controller({
|
|
6
|
-
// GET /session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
|
|
6
|
+
// GET {{URL_PREFIX}}/session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
|
|
7
7
|
async new() {
|
|
8
8
|
return this.render('Auth/Login', { error: null as string | null })
|
|
9
9
|
},
|
|
10
|
-
// POST /session — 로그인
|
|
10
|
+
// POST {{URL_PREFIX}}/session — 로그인
|
|
11
11
|
async create() {
|
|
12
12
|
const { email, password } = this.params({ _row: {} as { email: string; password: string } })
|
|
13
13
|
const user = await User.where('email', '=', email).first()
|
|
14
14
|
// passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
|
|
15
15
|
if (user && (await verifyPassword(password, user.passwordDigest))) {
|
|
16
16
|
this.auth.login(user)
|
|
17
|
-
return this.redirect('/dashboard')
|
|
17
|
+
return this.redirect('{{URL_PREFIX}}/dashboard')
|
|
18
18
|
}
|
|
19
19
|
return this.render('Auth/Login', {
|
|
20
20
|
error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
|
|
21
21
|
})
|
|
22
22
|
},
|
|
23
|
-
// DELETE /session — 로그아웃
|
|
23
|
+
// DELETE {{URL_PREFIX}}/session — 로그아웃
|
|
24
24
|
async destroy() {
|
|
25
25
|
this.auth.logout()
|
|
26
|
-
return this.redirect('/session/new')
|
|
26
|
+
return this.redirect('{{URL_PREFIX}}/session/new')
|
|
27
27
|
},
|
|
28
28
|
})
|
|
@@ -26,6 +26,7 @@ v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구
|
|
|
26
26
|
| 페이지 · 컴포넌트 · 컴포저블 · 레이아웃 · `api()` · bigint key | `agents/frontend.md` |
|
|
27
27
|
| 잡 · 이벤트 · 리스너 · 아웃박스 · 스케줄 | `agents/async.md` |
|
|
28
28
|
| 채널 · 프레즌스 · 허브 | `agents/realtime.md` |
|
|
29
|
+
| 파일 스토리지 (`Storage.put/url` · s3Disk · presigned · CSP 자동 배선) | `agents/storage.md` |
|
|
29
30
|
| 테스트 작성·실행 (실 인프라 · `expectJobProcessed`) | `agents/testing.md` |
|
|
30
31
|
| 보안 기본값 · 탈출구(v-html · raw SQL) 사용 | `agents/security.md` |
|
|
31
32
|
| 페이로드 봉인 (`@gaonjs/seal` · wire/문서/WS 암호화 · 선택 플러그인) | `agents/seal.md` |
|
|
@@ -105,12 +106,12 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
105
106
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
106
107
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
107
108
|
|
|
108
|
-
### 2.2 `gaon doctor` 검사
|
|
109
|
+
### 2.2 `gaon doctor` 검사 25종
|
|
109
110
|
|
|
110
111
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
111
112
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
112
113
|
3. `dependency-direction` — 의존 방향 4규칙 위반
|
|
113
|
-
4. `connections` — 커넥션
|
|
114
|
+
4. `connections` — 스키마·`getConnection` 이 쓰는 커넥션 키가 `gaon.config.ts` 에 등록됐는지 · db 설정 정적 분석(삼항·`??` 지원 · 못 읽으면 안내) (§4.5 · 결정 135)
|
|
114
115
|
5. `migration-diff` — 스키마 vs DB 상태 불일치
|
|
115
116
|
6. `shared-composable-purity` — shared 안 `api`/`pageProps` import (결정 25)
|
|
116
117
|
7. `no-auto-import` — 자동 import 설정 (E-5 §2.4)
|
|
@@ -131,6 +132,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
131
132
|
22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
|
|
132
133
|
23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
|
|
133
134
|
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon check --fix` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
135
|
+
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
|
|
134
136
|
|
|
135
137
|
## 3. 로직 배치 One Way 판단표
|
|
136
138
|
|
|
@@ -219,7 +221,10 @@ gaon doctor # 정적 검사 24종 (§2.2)
|
|
|
219
221
|
|
|
220
222
|
**스케일링 구분** — `serve --workers N`(한 포트 · node:cluster 수직) vs 웹 인스턴스
|
|
221
223
|
여러 대(각각 다른 `PORT` · 허브 뒤 수평) vs `work`(포트 없음 · 프로세스만)는 서로
|
|
222
|
-
다르다.
|
|
224
|
+
다르다. 워커 다중화 시 스케줄 발행=단일 리더 · 소비=워커 분산 계약은
|
|
225
|
+
`agents/async.md` §5 "리더 선출 계약" 이 정본이다(exactly-once 발행 · `gaon serve`
|
|
226
|
+
는 스케줄러 미실행). 로컬 멀티 인스턴스 레시피·구분표는 프레임웍 문서
|
|
227
|
+
`gaonjs.dev` 의 operations 가이드(`docs/guides/operations.md`)를 참고한다.
|
|
223
228
|
|
|
224
229
|
## 5. npm 배포본 — 패키지 → 역할
|
|
225
230
|
|
|
@@ -258,8 +263,9 @@ gaon doctor # 정적 검사 24종 (§2.2)
|
|
|
258
263
|
이력 동결 = `gaondesignv0.16.md`·`v0.15.md` + errata E-1~E-5
|
|
259
264
|
(E-1 파사드명 · E-2 실시간 TCP · E-3 JSON 액션/params · E-4 컬럼·
|
|
260
265
|
체이닝 · E-5 컴포저블·레이아웃).
|
|
261
|
-
-
|
|
262
|
-
|
|
263
|
-
operations · configuration
|
|
266
|
+
- 가이드(프레임웍 문서 · `gaonjs.dev` · 사용자 프로젝트엔 동봉 안 됨):
|
|
267
|
+
getting-started · data · data-flow · serialization · authentication ·
|
|
268
|
+
realtime · async · pipeline · operations · configuration. 작업별 정본은
|
|
269
|
+
위 §0 표의 `agents/*.md`(스캐폴드에 동봉) 를 먼저 읽는다.
|
|
264
270
|
- 프레임웍 구현 저장소의 AI 지침은 `CLAUDE.md` — 이 문서와 대상이
|
|
265
271
|
다르다 (CLAUDE = 프레임웍 구현자용, AGENTS = 프레임웍 사용자용).
|
|
@@ -186,15 +186,19 @@ export const PlaceOrder = service(async (input: { name: string }) => {
|
|
|
186
186
|
```
|
|
187
187
|
|
|
188
188
|
- 트랜잭션 안의 `emit` 은 `AsyncLocalStorage` 로 투명하게 감지돼
|
|
189
|
-
아웃박스에 스테이징된다(별도 API 호출 불필요).
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
189
|
+
아웃박스에 스테이징된다(별도 API 호출 불필요). `service()` 본문의 emit 이
|
|
190
|
+
같은 트랜잭션으로 `_gaon_outbox` 에 적재되고, 롤백 시 적재도 취소된다
|
|
191
|
+
(결정 144 · 12차 W3 — 표준 부팅 `gaon serve`·`gaon work` 가 이 배선을
|
|
192
|
+
자동으로 한다 · 앱 개발자가 부팅 코드를 짤 필요 없다).
|
|
193
|
+
- 대부분의 도메인 코드는 `service()` 본문에서 emit 하거나, "커밋 후 즉시
|
|
194
|
+
발행"이면 `afterCommit(fn)`(§서비스)을 쓴다. 저수준 원시 `runInTransaction(db, fn)`
|
|
195
|
+
(커넥션을 직접 넘긴다)은 프레임웍 밖에서 트랜잭션을 손수 열 때만 쓰는 탈출구다.
|
|
193
196
|
- 릴레이(`gaon work` 내장)가 `SKIP LOCKED` 로 아웃박스를 폴링해 발행
|
|
194
197
|
한다 (기본 폴 1000ms · 배치 100).
|
|
195
198
|
- at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
|
|
196
199
|
흡수한다.
|
|
197
|
-
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며
|
|
200
|
+
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
|
|
201
|
+
기동 시 보장된다(결정 144 · nats 설정이 있을 때).
|
|
198
202
|
- 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
|
|
199
203
|
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `gaon work` 의
|
|
200
204
|
`outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(운영 상세는
|
|
@@ -226,6 +230,23 @@ export default schedule((s) => {
|
|
|
226
230
|
| `s.daily.at('HH:MM', Job)` | 매일 지정 시각 |
|
|
227
231
|
| `s.cron('분 시 일 월 요일', Job)` | 5필드 크론 표현식 |
|
|
228
232
|
|
|
233
|
+
#### 리더 선출 계약 (정본)
|
|
234
|
+
|
|
235
|
+
여러 `gaon work` 인스턴스를 HA 로 띄워도 스케줄이 중복 발행되지 않는 이유:
|
|
236
|
+
|
|
237
|
+
- **스케줄 발행 = 단일 리더** — 워커들이 NATS KV 리스(lease)로 리더 하나를
|
|
238
|
+
선출하고, 그 리더만 `s.every`·`s.cron` 시각에 잡을 **발행**한다. 리더가
|
|
239
|
+
죽으면 리스가 만료돼 다른 워커가 승계한다(active-standby).
|
|
240
|
+
- **소비 = 워커 전체 분산** — 발행된 잡은 NATS JetStream 큐 그룹으로 **모든**
|
|
241
|
+
워커에 로드밸런싱된다. 발행은 1인, 처리는 N인.
|
|
242
|
+
- **exactly-once(발행 기준)** — 한 스케줄 틱은 리더 1인이 한 번만 발행한다.
|
|
243
|
+
잡 자체는 재시도(백오프)가 있으니 **핸들러는 멱등**하게 짠다(같은 잡이 두 번
|
|
244
|
+
처리돼도 안전하게).
|
|
245
|
+
- **`gaon serve` 는 스케줄러를 돌리지 않는다** — 스케줄·리더 선출·아웃박스
|
|
246
|
+
릴레이는 **`gaon work` 전용**이다. 웹 프로세스는 잡을 **발행**만 할 수 있고
|
|
247
|
+
(`.later()`), 처리·스케줄은 워커가 한다. 스케줄이 안 도는 흔한 원인은
|
|
248
|
+
`gaon work` 를 안 띄운 것이다.
|
|
249
|
+
|
|
229
250
|
### 6. 워커 프로세스 (`gaon work`)
|
|
230
251
|
|
|
231
252
|
잡·리스너·스케줄러·아웃박스 릴레이를 한 프로세스로 조립한다. 운영
|