@gaonjs/cli 0.15.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/commands/g.js +8 -0
- package/dist/commands/new.js +5 -0
- package/dist/dev/health.d.ts +4 -1
- package/dist/dev/health.js +5 -3
- 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 +3 -3
- package/dist/doctor/ui-kit-wiring.d.ts +5 -0
- package/dist/doctor/ui-kit-wiring.js +93 -0
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +19 -2
- package/dist/generate.js +8 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +19 -5
- package/dist/scaffold/app-wiring.d.ts +12 -0
- package/dist/scaffold/app-wiring.js +68 -0
- package/dist/serve.d.ts +10 -0
- package/dist/serve.js +90 -2
- package/dist/templates/auth/Dashboard.vue.tpl +21 -5
- package/dist/templates/auth/Login.vue.tpl +37 -8
- package/dist/templates/auth/Signup.vue.tpl +40 -9
- package/dist/templates/project/.dockerignore.tpl +12 -0
- package/dist/templates/project/AGENTS.md.tpl +10 -5
- package/dist/templates/project/CLAUDE.md.tpl +4 -3
- package/dist/templates/project/Dockerfile.tpl +30 -0
- package/dist/templates/project/agents/async.md.tpl +19 -7
- package/dist/templates/project/agents/data.md.tpl +37 -15
- package/dist/templates/project/agents/frontend.md.tpl +81 -4
- 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/composables/useGaonHealth.ts.tpl +2 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +19 -94
- package/dist/templates/project/apps/web/main.ts.tpl +4 -0
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +68 -251
- package/dist/templates/project/apps/web/static/robots.txt.tpl +4 -0
- package/dist/templates/project/apps/web/style.css.tpl +66 -0
- package/dist/templates/project/compose.prod.yaml.tpl +98 -0
- package/dist/templates/project/package.json.tpl +4 -0
- package/dist/templates/project/postcss.config.js.tpl +15 -0
- package/dist/templates/project/tailwind.config.ts.tpl +56 -0
- package/dist/templates/ui-kit/Alert.vue.tpl +23 -0
- package/dist/templates/ui-kit/AlertDescription.vue.tpl +9 -0
- package/dist/templates/ui-kit/AlertTitle.vue.tpl +9 -0
- package/dist/templates/ui-kit/Badge.vue.tpl +25 -0
- package/dist/templates/ui-kit/Button.vue.tpl +39 -0
- package/dist/templates/ui-kit/Card.vue.tpl +10 -0
- package/dist/templates/ui-kit/CardContent.vue.tpl +9 -0
- package/dist/templates/ui-kit/CardDescription.vue.tpl +9 -0
- package/dist/templates/ui-kit/CardFooter.vue.tpl +9 -0
- package/dist/templates/ui-kit/CardHeader.vue.tpl +9 -0
- package/dist/templates/ui-kit/CardTitle.vue.tpl +9 -0
- package/dist/templates/ui-kit/Dialog.vue.tpl +68 -0
- package/dist/templates/ui-kit/Form.vue.tpl +13 -0
- package/dist/templates/ui-kit/FormField.vue.tpl +16 -0
- package/dist/templates/ui-kit/FormMessage.vue.tpl +9 -0
- package/dist/templates/ui-kit/Input.vue.tpl +22 -0
- package/dist/templates/ui-kit/Label.vue.tpl +9 -0
- package/dist/templates/ui-kit/Sheet.vue.tpl +73 -0
- package/dist/templates/ui-kit/utils.ts.tpl +26 -0
- package/dist/uikit.d.ts +28 -0
- package/dist/uikit.js +138 -0
- package/dist/work.js +2 -0
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { runNewCommand } from "./commands/new.js";
|
|
|
17
17
|
import { runConsoleCommand } from "./commands/console.js";
|
|
18
18
|
import { runTestCommand } from "./commands/test.js";
|
|
19
19
|
import { runGenerateAuthCommand } from "./generate.js";
|
|
20
|
+
import { runGenerateUiKitCommand } from "./uikit.js";
|
|
20
21
|
import { runGenerateCommand } from "./commands/g.js";
|
|
21
22
|
import { runHubCommand } from "./hub.js";
|
|
22
23
|
import { runServeCommand } from "./serve.js";
|
|
@@ -25,7 +26,7 @@ import { runJobsCommand } from "./jobs.js";
|
|
|
25
26
|
import { runDbCommand } from "./commands/db.js";
|
|
26
27
|
import { runDoctorCommand } from "./doctor.js";
|
|
27
28
|
import { runMcpCommand } from "./commands/mcp.js";
|
|
28
|
-
export { startDev, resolveDevLayout, } from "./dev.js";
|
|
29
|
+
export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
|
|
29
30
|
export { runDevCommand } from "./commands/dev.js";
|
|
30
31
|
export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, } from "./dev/index.js";
|
|
31
32
|
export { runCheckCommand, } from "./commands/check.js";
|
|
@@ -33,6 +34,7 @@ export { runNewCommand } from "./commands/new.js";
|
|
|
33
34
|
export { runConsoleCommand } from "./commands/console.js";
|
|
34
35
|
export { runTestCommand } from "./commands/test.js";
|
|
35
36
|
export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, } from "./generate.js";
|
|
37
|
+
export { writeUiKitScaffold, writeUiKitFiles, uiKitScaffoldFiles, authUiKitFiles, runGenerateUiKitCommand, } from "./uikit.js";
|
|
36
38
|
export { runGenerateCommand, planScaffold, parseGenerateArgs, } from "./commands/g.js";
|
|
37
39
|
export { runHubCommand } from "./hub.js";
|
|
38
40
|
export { runServeCommand } from "./serve.js";
|
|
@@ -98,10 +100,11 @@ function renderHelp(version = VERSION) {
|
|
|
98
100
|
" gaon dev --json 통합 콘솔을 JSON 라인으로 출력(자동화)",
|
|
99
101
|
" gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
|
|
100
102
|
" gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
|
|
103
|
+
" gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
|
|
101
104
|
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
102
105
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
103
106
|
" gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
|
|
104
|
-
" gaon doctor 정적 검사 (
|
|
107
|
+
" gaon doctor 정적 검사 (17 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method)",
|
|
105
108
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
106
109
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
107
110
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -192,11 +195,15 @@ export function runCli(argv, opts = {}) {
|
|
|
192
195
|
if (argv[0] === "serve") {
|
|
193
196
|
const portIdx = argv.indexOf("--port");
|
|
194
197
|
const hostIdx = argv.indexOf("--host");
|
|
198
|
+
const workersIdx = argv.indexOf("--workers");
|
|
195
199
|
const port = portIdx >= 0 ? Number(argv[portIdx + 1]) : undefined;
|
|
196
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;
|
|
197
204
|
// --dev: dev 전용 진단 라우트(/_gaon/health) 등록. gaon dev 가 자식
|
|
198
205
|
// serve 에 넘긴다(결정 69 · dev-only by construction).
|
|
199
|
-
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) => {
|
|
200
207
|
const msg = err instanceof Error ? err.message : String(err);
|
|
201
208
|
process.stderr.write(` ✗ gaon serve 실패: ${msg}\n`);
|
|
202
209
|
process.exitCode = 1;
|
|
@@ -228,7 +235,7 @@ export function runCli(argv, opts = {}) {
|
|
|
228
235
|
});
|
|
229
236
|
return;
|
|
230
237
|
}
|
|
231
|
-
// `gaon doctor` — 정적 검사(M9-E ·
|
|
238
|
+
// `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
|
|
232
239
|
// 선택 실행, --json 은 자동화 파싱용.
|
|
233
240
|
// exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
|
|
234
241
|
if (argv[0] === "doctor") {
|
|
@@ -351,6 +358,13 @@ export function runCli(argv, opts = {}) {
|
|
|
351
358
|
process.exitCode = code;
|
|
352
359
|
return;
|
|
353
360
|
}
|
|
361
|
+
if (argv[1] === "ui-kit" || argv[1] === "ui") {
|
|
362
|
+
const appIdx = argv.indexOf("--app");
|
|
363
|
+
const app = appIdx >= 0 ? argv[appIdx + 1] : undefined;
|
|
364
|
+
const code = runGenerateUiKitCommand({ app, json: argv.includes("--json") });
|
|
365
|
+
process.exitCode = code;
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
354
368
|
const known = ["controller", "model", "page", "job", "app"];
|
|
355
369
|
const type = argv[1];
|
|
356
370
|
if (type && known.includes(type)) {
|
|
@@ -395,7 +409,7 @@ export function runCli(argv, opts = {}) {
|
|
|
395
409
|
return;
|
|
396
410
|
}
|
|
397
411
|
process.stderr.write(` ✗ 알 수 없는 제너레이터: ${argv[1] ?? "(없음)"}\n` +
|
|
398
|
-
` → 현재 지원: gaon g auth | controller | model | page | job | app\n` +
|
|
412
|
+
` → 현재 지원: gaon g auth | ui-kit | controller | model | page | job | app\n` +
|
|
399
413
|
` → 옵션: --app <이름> · --overwrite · --json\n`);
|
|
400
414
|
process.exitCode = 1;
|
|
401
415
|
return;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ScaffoldFile } from './controller.js';
|
|
2
|
+
/**
|
|
3
|
+
* 대상 앱의 프론트 배선 파일(main.ts·style.css·index.html)을 web 정본에서
|
|
4
|
+
* 파생해 반환한다. 실제 쓰기는 writeScaffold/writeUiKitFiles 가 담당(멱등).
|
|
5
|
+
*/
|
|
6
|
+
export declare function appWiringFiles(app: string, projectName: string): ScaffoldFile[];
|
|
7
|
+
/** 배선 파일 3종의 대상 경로(존재 검사용). */
|
|
8
|
+
export declare function appWiringPaths(app: string): string[];
|
|
9
|
+
/** 대상 앱에 Tailwind 배선(style.css + main.ts 의 style.css import)이 있는지. */
|
|
10
|
+
export declare function hasAppWiring(cwd: string, app: string): boolean;
|
|
11
|
+
/** 프로젝트 package.json 의 name — 배선 title 치환용. 없으면 'app'. */
|
|
12
|
+
export declare function readProjectName(cwd: string): string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @gaonjs/cli · scaffold · app-wiring — 앱 프론트 배선 (결정 76)
|
|
2
|
+
//
|
|
3
|
+
// 새 앱(`gaon g app admin`)과 web 앱(`gaon new`)이 프론트 배선에서 동등해야
|
|
4
|
+
// 한다: Tailwind CSS 파이프라인(style.css)·Vite 진입(main.ts·index.html).
|
|
5
|
+
// 이 배선이 없으면 그 앱에서 `gaon g ui-kit --app admin` 으로 심은 UI 킷
|
|
6
|
+
// 컴포넌트가 Tailwind 유틸을 못 받아 스타일 없이 렌더된다(결정 74·75).
|
|
7
|
+
//
|
|
8
|
+
// 단일 소스: web 앱의 정본 템플릿(templates/project/apps/web/{main.ts,style.css,
|
|
9
|
+
// index.html}.tpl)을 그대로 읽어 `{{PROJECT_NAME}}` 만 치환하고 대상 앱으로
|
|
10
|
+
// 재타겟한다. style.css 는 앱 무관(토큰만)이라 web 과 바이트 동일하고, main.ts·
|
|
11
|
+
// index.html 은 프로젝트명 title 만 다르다 — 템플릿을 복제하지 않아 드리프트가
|
|
12
|
+
// 없다(결정 76).
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { renderTemplate, templateDir } from '../templates/index.js';
|
|
16
|
+
/** web 정본 템플릿을 대상 앱으로 재타겟하는 배선 파일 3종의 원본 경로. */
|
|
17
|
+
const WIRING = [
|
|
18
|
+
{ tpl: 'apps/web/main.ts.tpl', out: 'main.ts' },
|
|
19
|
+
{ tpl: 'apps/web/style.css.tpl', out: 'style.css' },
|
|
20
|
+
{ tpl: 'apps/web/index.html.tpl', out: 'index.html' },
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* 대상 앱의 프론트 배선 파일(main.ts·style.css·index.html)을 web 정본에서
|
|
24
|
+
* 파생해 반환한다. 실제 쓰기는 writeScaffold/writeUiKitFiles 가 담당(멱등).
|
|
25
|
+
*/
|
|
26
|
+
export function appWiringFiles(app, projectName) {
|
|
27
|
+
const base = templateDir();
|
|
28
|
+
const tokens = { projectName, gaonjsVersion: readGaonjsVersion() };
|
|
29
|
+
return WIRING.map(({ tpl, out }) => {
|
|
30
|
+
const raw = readFileSync(join(base, tpl), 'utf8');
|
|
31
|
+
// 주석 안 `apps/web/…` 경로 표기를 대상 앱으로 맞춘다(import 는 상대라 무관).
|
|
32
|
+
const retargeted = renderTemplate(raw, tokens).replaceAll('apps/web/', `apps/${app}/`);
|
|
33
|
+
return { path: `apps/${app}/${out}`, contents: retargeted };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** 배선 파일 3종의 대상 경로(존재 검사용). */
|
|
37
|
+
export function appWiringPaths(app) {
|
|
38
|
+
return WIRING.map(({ out }) => `apps/${app}/${out}`);
|
|
39
|
+
}
|
|
40
|
+
/** 대상 앱에 Tailwind 배선(style.css + main.ts 의 style.css import)이 있는지. */
|
|
41
|
+
export function hasAppWiring(cwd, app) {
|
|
42
|
+
const styleCss = join(cwd, 'apps', app, 'style.css');
|
|
43
|
+
const mainTs = join(cwd, 'apps', app, 'main.ts');
|
|
44
|
+
if (!existsSync(styleCss) || !existsSync(mainTs))
|
|
45
|
+
return false;
|
|
46
|
+
const css = readFileSync(styleCss, 'utf8');
|
|
47
|
+
const main = readFileSync(mainTs, 'utf8');
|
|
48
|
+
return /@tailwind\b/.test(css) && /['"]\.\/style\.css['"]/.test(main);
|
|
49
|
+
}
|
|
50
|
+
/** 프로젝트 package.json 의 name — 배선 title 치환용. 없으면 'app'. */
|
|
51
|
+
export function readProjectName(cwd) {
|
|
52
|
+
return readPackageField(cwd, (pkg) => (typeof pkg.name === 'string' ? pkg.name : undefined)) ?? 'app';
|
|
53
|
+
}
|
|
54
|
+
/** 설치된 gaonjs 버전(배선 템플릿엔 미사용이나 렌더 계약상 채운다). */
|
|
55
|
+
function readGaonjsVersion() {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
function readPackageField(cwd, pick) {
|
|
59
|
+
const pj = join(cwd, 'package.json');
|
|
60
|
+
if (!existsSync(pj))
|
|
61
|
+
return undefined;
|
|
62
|
+
try {
|
|
63
|
+
return pick(JSON.parse(readFileSync(pj, 'utf8')));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
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
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { pageProps, router } from 'gaonjs/vue'
|
|
3
|
+
import Card from '../components/ui/Card.vue'
|
|
4
|
+
import CardHeader from '../components/ui/CardHeader.vue'
|
|
5
|
+
import CardTitle from '../components/ui/CardTitle.vue'
|
|
6
|
+
import CardDescription from '../components/ui/CardDescription.vue'
|
|
7
|
+
import CardContent from '../components/ui/CardContent.vue'
|
|
8
|
+
import CardFooter from '../components/ui/CardFooter.vue'
|
|
9
|
+
import Button from '../components/ui/Button.vue'
|
|
3
10
|
|
|
4
11
|
// dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
|
|
5
12
|
const { user, csrf } = pageProps<'{{APP_NAME}}:dashboard#show'>()
|
|
@@ -14,9 +21,18 @@ function logout(): void {
|
|
|
14
21
|
</script>
|
|
15
22
|
|
|
16
23
|
<template>
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
<div class="mx-auto max-w-2xl px-4 py-10">
|
|
25
|
+
<Card>
|
|
26
|
+
<CardHeader>
|
|
27
|
+
<CardTitle>환영합니다, {{ user.name }}님</CardTitle>
|
|
28
|
+
<CardDescription>{{ user.email }}</CardDescription>
|
|
29
|
+
</CardHeader>
|
|
30
|
+
<CardContent>
|
|
31
|
+
<p class="text-sm text-muted-foreground">보호된 페이지입니다 — this.requireAuth() 로 지킵니다.</p>
|
|
32
|
+
</CardContent>
|
|
33
|
+
<CardFooter>
|
|
34
|
+
<Button variant="outline" @click="logout">로그아웃</Button>
|
|
35
|
+
</CardFooter>
|
|
36
|
+
</Card>
|
|
37
|
+
</div>
|
|
22
38
|
</template>
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { pageProps, useForm } from 'gaonjs/vue'
|
|
3
|
+
import Card from '../../components/ui/Card.vue'
|
|
4
|
+
import CardHeader from '../../components/ui/CardHeader.vue'
|
|
5
|
+
import CardTitle from '../../components/ui/CardTitle.vue'
|
|
6
|
+
import CardDescription from '../../components/ui/CardDescription.vue'
|
|
7
|
+
import CardContent from '../../components/ui/CardContent.vue'
|
|
8
|
+
import Form from '../../components/ui/Form.vue'
|
|
9
|
+
import FormField from '../../components/ui/FormField.vue'
|
|
10
|
+
import Input from '../../components/ui/Input.vue'
|
|
11
|
+
import Button from '../../components/ui/Button.vue'
|
|
12
|
+
import Alert from '../../components/ui/Alert.vue'
|
|
13
|
+
import AlertDescription from '../../components/ui/AlertDescription.vue'
|
|
3
14
|
|
|
4
15
|
// 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2).
|
|
5
16
|
const { error, csrf } = pageProps<'{{APP_NAME}}:session#new'>()
|
|
@@ -10,12 +21,30 @@ const form = useForm({ email: '', password: '', _csrf: csrf })
|
|
|
10
21
|
</script>
|
|
11
22
|
|
|
12
23
|
<template>
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
<div class="mx-auto flex min-h-[70vh] max-w-sm items-center px-4">
|
|
25
|
+
<Card class="w-full">
|
|
26
|
+
<CardHeader>
|
|
27
|
+
<CardTitle>로그인</CardTitle>
|
|
28
|
+
<CardDescription>계정으로 로그인하세요.</CardDescription>
|
|
29
|
+
</CardHeader>
|
|
30
|
+
<CardContent>
|
|
31
|
+
<Alert v-if="error" variant="destructive" class="mb-4">
|
|
32
|
+
<AlertDescription>{{ error }}</AlertDescription>
|
|
33
|
+
</Alert>
|
|
34
|
+
<Form @submit="form.post('/session')">
|
|
35
|
+
<FormField label="이메일" :error="form.errors.email">
|
|
36
|
+
<Input v-model="form.email" type="email" required />
|
|
37
|
+
</FormField>
|
|
38
|
+
<FormField label="비밀번호" :error="form.errors.password">
|
|
39
|
+
<Input v-model="form.password" type="password" required />
|
|
40
|
+
</FormField>
|
|
41
|
+
<Button type="submit" class="w-full" :disabled="form.processing">로그인</Button>
|
|
42
|
+
</Form>
|
|
43
|
+
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
44
|
+
계정이 없으신가요?
|
|
45
|
+
<a href="/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</a>
|
|
46
|
+
</p>
|
|
47
|
+
</CardContent>
|
|
48
|
+
</Card>
|
|
49
|
+
</div>
|
|
21
50
|
</template>
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { pageProps, useForm } from 'gaonjs/vue'
|
|
3
|
+
import Card from '../../components/ui/Card.vue'
|
|
4
|
+
import CardHeader from '../../components/ui/CardHeader.vue'
|
|
5
|
+
import CardTitle from '../../components/ui/CardTitle.vue'
|
|
6
|
+
import CardDescription from '../../components/ui/CardDescription.vue'
|
|
7
|
+
import CardContent from '../../components/ui/CardContent.vue'
|
|
8
|
+
import Form from '../../components/ui/Form.vue'
|
|
9
|
+
import FormField from '../../components/ui/FormField.vue'
|
|
10
|
+
import Input from '../../components/ui/Input.vue'
|
|
11
|
+
import Button from '../../components/ui/Button.vue'
|
|
12
|
+
import Alert from '../../components/ui/Alert.vue'
|
|
13
|
+
import AlertDescription from '../../components/ui/AlertDescription.vue'
|
|
3
14
|
|
|
4
15
|
const { error, csrf } = pageProps<'{{APP_NAME}}:registration#new'>()
|
|
5
16
|
|
|
@@ -8,13 +19,33 @@ const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
|
|
|
8
19
|
</script>
|
|
9
20
|
|
|
10
21
|
<template>
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
<div class="mx-auto flex min-h-[70vh] max-w-sm items-center px-4">
|
|
23
|
+
<Card class="w-full">
|
|
24
|
+
<CardHeader>
|
|
25
|
+
<CardTitle>회원가입</CardTitle>
|
|
26
|
+
<CardDescription>새 계정을 만드세요.</CardDescription>
|
|
27
|
+
</CardHeader>
|
|
28
|
+
<CardContent>
|
|
29
|
+
<Alert v-if="error" variant="destructive" class="mb-4">
|
|
30
|
+
<AlertDescription>{{ error }}</AlertDescription>
|
|
31
|
+
</Alert>
|
|
32
|
+
<Form @submit="form.post('/registration')">
|
|
33
|
+
<FormField label="이름" :error="form.errors.name">
|
|
34
|
+
<Input v-model="form.name" required />
|
|
35
|
+
</FormField>
|
|
36
|
+
<FormField label="이메일" :error="form.errors.email">
|
|
37
|
+
<Input v-model="form.email" type="email" required />
|
|
38
|
+
</FormField>
|
|
39
|
+
<FormField label="비밀번호" :error="form.errors.password">
|
|
40
|
+
<Input v-model="form.password" type="password" required />
|
|
41
|
+
</FormField>
|
|
42
|
+
<Button type="submit" class="w-full" :disabled="form.processing">회원가입</Button>
|
|
43
|
+
</Form>
|
|
44
|
+
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
45
|
+
이미 계정이 있으신가요?
|
|
46
|
+
<a href="/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</a>
|
|
47
|
+
</p>
|
|
48
|
+
</CardContent>
|
|
49
|
+
</Card>
|
|
50
|
+
</div>
|
|
20
51
|
</template>
|
|
@@ -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)
|
|
@@ -119,6 +119,10 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
119
119
|
11. `model-filename` — 모델 파일명 PascalCase 관례 (결정 32·46 · `--fix` 지원)
|
|
120
120
|
12. `page-filename` — Vue 페이지 파일명 PascalCase 관례 (결정 32·46)
|
|
121
121
|
13. `auth-wiring` — requireAuth/this.auth 사용 ↔ `app.config.ts` 인증 배선 (결정 59)
|
|
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 · 경고)
|
|
122
126
|
|
|
123
127
|
## 3. 로직 배치 One Way 판단표
|
|
124
128
|
|
|
@@ -159,7 +163,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
159
163
|
```bash
|
|
160
164
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
|
|
161
165
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
162
|
-
gaon doctor # 정적 검사
|
|
166
|
+
gaon doctor # 정적 검사 17종 (§2.2)
|
|
163
167
|
```
|
|
164
168
|
|
|
165
169
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -209,7 +213,8 @@ gaon doctor # 정적 검사 13종 (§2.2)
|
|
|
209
213
|
|
|
210
214
|
## 7. 참고 문서
|
|
211
215
|
|
|
212
|
-
- 설계 정본: `docs/gaondesignv0.
|
|
216
|
+
- 설계 정본: `docs/gaondesignv0.17.md` (v1.0 출시 기준 스냅샷 · 결정 31~89) ·
|
|
217
|
+
이력 동결 = `gaondesignv0.16.md`·`v0.15.md` + errata E-1~E-5
|
|
213
218
|
(E-1 파사드명 · E-2 실시간 TCP · E-3 JSON 액션/params · E-4 컬럼·
|
|
214
219
|
체이닝 · E-5 컴포저블·레이아웃).
|
|
215
220
|
- 가이드: `docs/guides/*.md` (getting-started · data · data-flow ·
|
|
@@ -36,8 +36,9 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
36
36
|
9. **실시간은 v1 포함**(§7): 웹서버 ↔ 허브는 TCP 지속 연결 · NATS 는
|
|
37
37
|
broadcast 전용(errata E-2). 운영 프로세스는 serve·work·hub 3종.
|
|
38
38
|
10. **인증·폼은 Inertia SPA**(§6 · SSR 아님). 로그인/회원가입은
|
|
39
|
-
`this.render('Auth/Login')` + `
|
|
40
|
-
|
|
39
|
+
`this.render('Auth/Login')` + `gaonjs/vue` 의 `useForm(...).post()`
|
|
40
|
+
(DELETE 등은 `router`) → 서버 redirect(결정 64). REST + `fetch()`
|
|
41
|
+
는 API 앱(JWT) 전용.
|
|
41
42
|
11. **컴포저블·레이아웃**(errata E-5): 컴포저블은 컴포넌트와 대칭
|
|
42
43
|
(`apps/<앱>/composables/` + `shared/composables/`, `use` 접두사).
|
|
43
44
|
shared 컴포저블은 인자로만 받는 순수 로직(api·pageProps 금지).
|
|
@@ -84,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
84
85
|
|
|
85
86
|
```bash
|
|
86
87
|
gaon check # .gaon 재생성 후 타입 검사 (CI 정합)
|
|
87
|
-
gaon doctor # 정적 검사
|
|
88
|
+
gaon doctor # 정적 검사 17종 (응답·N+1·의존·커넥션·마이그·순수·자동import·파일명/컬럼·인증·UI킷·라우트 · 상세 AGENTS §2.2)
|
|
88
89
|
npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
|
|
89
90
|
```
|
|
90
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"]
|
|
@@ -115,26 +115,38 @@ await OrderPlaced.emit({ orderId: 1n })
|
|
|
115
115
|
### 4. 아웃박스 (트랜잭션 정합)
|
|
116
116
|
|
|
117
117
|
이벤트를 DB 트랜잭션과 **원자적으로** 발행하려면 아웃박스를 쓴다.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
트랜잭션 안에서 발행한 이벤트는 같은 트랜잭션의 아웃박스 테이블에
|
|
119
|
+
스테이징되고, 트랜잭션이 커밋돼야 릴레이가 실제로 NATS 에 발행한다.
|
|
120
|
+
트랜잭션이 롤백되면 이벤트도 사라진다. `service()` 본문 전체가 한
|
|
121
|
+
트랜잭션이므로(§서비스), 본문 안 `emit` 이 곧 아웃박스 스테이징이다.
|
|
121
122
|
|
|
122
123
|
```ts
|
|
123
|
-
|
|
124
|
+
// domain/services/placeOrder.ts — service() 본문 = 한 트랜잭션.
|
|
125
|
+
import { service } from 'gaonjs/service'
|
|
126
|
+
import { Order } from '../models/Order.js'
|
|
127
|
+
import { OrderPlaced } from '../events/orderPlaced.js'
|
|
124
128
|
|
|
125
|
-
|
|
126
|
-
await Order.create(
|
|
127
|
-
await OrderPlaced.emit({ orderId }) // 커밋돼야
|
|
129
|
+
export const PlaceOrder = service(async (input: { name: string }) => {
|
|
130
|
+
const order = await Order.create(input)
|
|
131
|
+
await OrderPlaced.emit({ orderId: order.id }) // 트랜잭션 안 → 아웃박스 스테이징 · 커밋돼야 발행
|
|
132
|
+
return order
|
|
128
133
|
})
|
|
129
134
|
```
|
|
130
135
|
|
|
131
136
|
- 트랜잭션 안의 `emit` 은 `AsyncLocalStorage` 로 투명하게 감지돼
|
|
132
137
|
아웃박스에 스테이징된다(별도 API 호출 불필요).
|
|
138
|
+
- 저수준 원시는 `runInTransaction(db, fn)`(커넥션을 넘긴다) — `service()`
|
|
139
|
+
가 내부에서 이를 쓴다. 대부분의 도메인 코드는 `service()` 본문에서
|
|
140
|
+
emit 하거나, "커밋 후 즉시 발행"이면 `afterCommit(fn)`(§서비스)을 쓴다.
|
|
133
141
|
- 릴레이(`gaon work` 내장)가 `SKIP LOCKED` 로 아웃박스를 폴링해 발행
|
|
134
142
|
한다 (기본 폴 1000ms · 배치 100).
|
|
135
143
|
- at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
|
|
136
144
|
흡수한다.
|
|
137
145
|
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 워커 기동 시 보장된다.
|
|
146
|
+
- 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
|
|
147
|
+
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `gaon work` 의
|
|
148
|
+
`outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(운영 상세는
|
|
149
|
+
`docs/guides/operations.md`). 미발행 행은 절대 삭제되지 않는다.
|
|
138
150
|
|
|
139
151
|
### 5. 스케줄러
|
|
140
152
|
|