@gaonjs/cli 0.52.0 → 0.56.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/check.d.ts +2 -0
- package/dist/commands/check.js +43 -1
- package/dist/commands/db.js +9 -0
- package/dist/commands/gen.d.ts +2 -0
- package/dist/commands/gen.js +3 -1
- package/dist/commands/new.js +13 -0
- package/dist/commands/test.js +28 -4
- package/dist/db/journal.d.ts +4 -1
- package/dist/db/journal.js +37 -4
- package/dist/db/migrate.js +11 -11
- package/dist/db/replay.js +1 -1
- package/dist/db/resolve.d.ts +11 -1
- package/dist/db/resolve.js +24 -2
- package/dist/db/status.js +9 -6
- package/dist/db.js +26 -5
- package/dist/dev.js +2 -2
- package/dist/doctor/auth-wiring.js +5 -2
- package/dist/doctor/channel-collision.d.ts +9 -0
- package/dist/doctor/channel-collision.js +119 -0
- package/dist/doctor/fixers/index.d.ts +1 -1
- package/dist/doctor/fixers/index.js +6 -1
- package/dist/doctor/locale-parity.js +2 -2
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +10 -2
- package/dist/doctor.js +45 -3
- package/dist/generate.d.ts +5 -0
- package/dist/generate.js +11 -3
- package/dist/i18n-config.d.ts +20 -0
- package/dist/i18n-config.js +87 -12
- package/dist/index.js +111 -26
- package/dist/mcp/tools.js +4 -0
- package/dist/messages-gen.js +11 -3
- package/dist/scaffold/controller.js +3 -1
- package/dist/scaffold/page.js +6 -4
- package/dist/templates/project/AGENTS.md.tpl +9 -5
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/Dockerfile.tpl +11 -1
- package/dist/templates/project/agents/async.md.tpl +56 -14
- package/dist/templates/project/agents/data.md.tpl +146 -35
- package/dist/templates/project/agents/frontend.md.tpl +24 -10
- package/dist/templates/project/agents/i18n.md.tpl +32 -4
- package/dist/templates/project/agents/mail.md.tpl +6 -0
- package/dist/templates/project/agents/realtime.md.tpl +115 -16
- package/dist/templates/project/agents/seal.md.tpl +13 -4
- package/dist/templates/project/agents/security.md.tpl +29 -4
- package/dist/templates/project/agents/storage.md.tpl +57 -13
- package/dist/templates/project/agents/testing.md.tpl +58 -0
- package/dist/templates/project/agents/web.md.tpl +171 -15
- package/dist/work.d.ts +3 -0
- package/dist/work.js +4 -0
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import { runHubCommand } from "./hub.js";
|
|
|
27
27
|
import { runWorkCommand } from "./work.js";
|
|
28
28
|
import { runJobsCommand } from "./jobs.js";
|
|
29
29
|
import { runDbCommand } from "./commands/db.js";
|
|
30
|
-
import { runDoctorCommand, ALL_RULES } from "./doctor.js";
|
|
30
|
+
import { runDoctorCommand, ALL_RULES, RULE_SUMMARIES } from "./doctor.js";
|
|
31
31
|
import { runMcpCommand } from "./commands/mcp.js";
|
|
32
32
|
export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
|
|
33
33
|
export { runDevCommand } from "./commands/dev.js";
|
|
@@ -109,13 +109,17 @@ function renderHelp(version = VERSION) {
|
|
|
109
109
|
" gaon gen .gaon 타입 브리지 + api() 런타임 매니페스트만 재생성 (서버·검사 없이 · build 전제 · --json)",
|
|
110
110
|
" gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
|
|
111
111
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
112
|
-
" gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all
|
|
113
|
-
|
|
112
|
+
" gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all)",
|
|
113
|
+
" gaon test -- <인자> `--` 뒤는 vitest 로 그대로 전달 (gaon 이 안 가로챔 · 예: gaon test -- --json --reporter=json)",
|
|
114
|
+
// 열거는 RULE_SUMMARIES(doctor.ts) 를 단일 출처로 쓴다 — 손유지 산문이 뒤처져
|
|
115
|
+
// 새 검사가 help 에서 빠지던 표류 방지(타입이 전수 채움을 강제).
|
|
116
|
+
` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · ${ALL_RULES.map((r) => RULE_SUMMARIES[r]).join("·")})`,
|
|
114
117
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
115
118
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
116
119
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
117
120
|
" gaon doctor --fix --yes 실제 편집 적용(원본은 .bak-<타임스탬프> 로 자동 백업)",
|
|
118
|
-
" gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트 · --jwt --app <api> = API 앱 토큰 변형)",
|
|
121
|
+
" gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트 · --jwt --app <api> = API 앱 토큰 변형 · --public = 비-web 앱 공개 가입)",
|
|
122
|
+
" gaon g ui-kit UI 킷 스캐폴드 (Button·Input 등 원자 프리미티브 · --app <이름>)",
|
|
119
123
|
" gaon g controller <name> 컨트롤러 스캐폴드 (Rails 관례 · 페이지+JSON 액션)",
|
|
120
124
|
" gaon g model <Name> 모델 스캐폴드 (스키마+모델 · E-4 컬럼 예시)",
|
|
121
125
|
" gaon g page <Path/Name> Vue 페이지 (Inertia SPA · pageProps 브리지)",
|
|
@@ -147,19 +151,34 @@ function renderHelp(version = VERSION) {
|
|
|
147
151
|
* 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
|
|
148
152
|
*/
|
|
149
153
|
export function parseDoctorChecks(argv) {
|
|
150
|
-
// 인정 집합은 doctor.ts 의 ALL_RULES(정본
|
|
154
|
+
// 인정 집합은 doctor.ts 의 ALL_RULES(정본 29종)를 단일 출처로 쓴다 — 과거
|
|
151
155
|
// 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
|
|
152
156
|
// 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
|
|
153
157
|
const isKnown = (s) => ALL_RULES.includes(s);
|
|
154
158
|
const out = [];
|
|
159
|
+
const unknown = [];
|
|
155
160
|
for (const a of argv) {
|
|
156
161
|
if (a.startsWith("--check=")) {
|
|
157
162
|
for (const nm of a.slice("--check=".length).split(",")) {
|
|
158
|
-
if (isKnown(nm)
|
|
159
|
-
out.
|
|
163
|
+
if (isKnown(nm)) {
|
|
164
|
+
if (!out.includes(nm))
|
|
165
|
+
out.push(nm);
|
|
166
|
+
}
|
|
167
|
+
else if (nm !== "" && !unknown.includes(nm)) {
|
|
168
|
+
unknown.push(nm);
|
|
169
|
+
}
|
|
160
170
|
}
|
|
161
171
|
}
|
|
162
172
|
}
|
|
173
|
+
// 결정 411: 모르는 이름은 여전히 무시하되(안전 방향 — 전체 검사로 넓어짐) **조용히**
|
|
174
|
+
// 넘기지 않는다. 오타 하나가 "그 검사만 돌렸다" 는 착각으로 이어지고, 전부 오타면
|
|
175
|
+
// 29종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
|
|
176
|
+
if (unknown.length > 0) {
|
|
177
|
+
process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
|
|
178
|
+
` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +
|
|
179
|
+
(out.length === 0 ? " (인정된 이름이 없어 전체 검사를 실행합니다)" : "") +
|
|
180
|
+
"\n");
|
|
181
|
+
}
|
|
163
182
|
return out.length ? out : undefined;
|
|
164
183
|
}
|
|
165
184
|
/**
|
|
@@ -225,6 +244,20 @@ export function parseDbArgs(sub, argv) {
|
|
|
225
244
|
const dbIdx = argv.indexOf("--db");
|
|
226
245
|
const cfgIdx = argv.indexOf("--config");
|
|
227
246
|
const valueFlags = new Set(["--db", "--config"]);
|
|
247
|
+
// 결정 404: 값 플래그의 **값 부재**도 fail-loud. `gaon db migrate --db`(값 없이 끝)는
|
|
248
|
+
// db=undefined 가 돼 "커넥션 하나만" 이던 의도가 조용히 **전 커넥션 적용**으로 확장됐다
|
|
249
|
+
// (결정 139 순회 기본값과 결합해 파괴 반경이 커진다). readPortFlag(결정 240)와 같은 규약.
|
|
250
|
+
for (const f of valueFlags) {
|
|
251
|
+
const i = argv.indexOf(f);
|
|
252
|
+
if (i < 0)
|
|
253
|
+
continue;
|
|
254
|
+
const v = argv[i + 1];
|
|
255
|
+
if (v === undefined || v.startsWith("-")) {
|
|
256
|
+
throw new Error(`gaon db ${sub}: ${f} 값이 없습니다.\n` +
|
|
257
|
+
` → 예: gaon db ${sub} ${f} ${f === "--db" ? "main" : "gaon.config.ts"}\n` +
|
|
258
|
+
` → 값 없이 두면 ${f === "--db" ? "전 커넥션에 적용" : "기본 설정 파일 사용"}으로 조용히 넓어져 멈춥니다.`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
228
261
|
// 결정 358: db 는 파괴 방향 오동작이 가능한 명령군이라 미지 플래그를 조용히
|
|
229
262
|
// 무시하지 않는다 — `--dryrun`(오타)이 무시되면 dry-run 의도가 **실제 마이그레이션
|
|
230
263
|
// 적용**으로 반전된다(결정 267 down-위치 문제와 동형). 인정 집합 밖 `--*` 는 throw.
|
|
@@ -248,6 +281,19 @@ export function parseDbArgs(sub, argv) {
|
|
|
248
281
|
}
|
|
249
282
|
positionals.push(a);
|
|
250
283
|
}
|
|
284
|
+
// 결정 404: 미지 **위치 인자**도 막는다. 종전엔 positionals 가 'down' 판정에만 쓰이고
|
|
285
|
+
// 나머지는 버려져서 `gaon db migrate donw`(오타)·`rollback`(타 프레임웍 관례)이 무경고로
|
|
286
|
+
// **정방향 migrate 실 적용**으로 반전됐다 — 결정 358 이 플래그에서 막은 것과 같은 파괴
|
|
287
|
+
// 방향인데 위치 인자만 뚫려 있었다.
|
|
288
|
+
const allowedPositionals = sub === "migrate" ? ["down"] : [];
|
|
289
|
+
const unknown = positionals.find((p) => !allowedPositionals.includes(p));
|
|
290
|
+
if (unknown !== undefined) {
|
|
291
|
+
throw new Error(`gaon db ${sub}: 알 수 없는 인자 '${unknown}'.\n` +
|
|
292
|
+
(sub === "migrate"
|
|
293
|
+
? ` → 지원 인자: down(롤백) 하나뿐입니다. 예: gaon db migrate down\n`
|
|
294
|
+
: ` → gaon db ${sub} 는 위치 인자를 받지 않습니다.\n`) +
|
|
295
|
+
` → 오타가 조용히 무시되면 의도와 반대로(예: 롤백 대신 정방향 적용) 실행될 수 있어 멈춥니다.`);
|
|
296
|
+
}
|
|
251
297
|
return {
|
|
252
298
|
json: argv.includes("--json"),
|
|
253
299
|
db: dbIdx >= 0 ? argv[dbIdx + 1] : undefined,
|
|
@@ -372,9 +418,17 @@ export function runCli(argv, opts = {}) {
|
|
|
372
418
|
const knownSteps = ["typecheck", "vue-tsc", "build", "doctor"];
|
|
373
419
|
const onlyIdx = argv.indexOf("--only");
|
|
374
420
|
const onlyRaw = onlyIdx >= 0 ? argv[onlyIdx + 1] : undefined;
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
421
|
+
// 결정 411: `--only` 오타를 조용히 전체 실행으로 폴백하지 않는다 — "typecheck 만
|
|
422
|
+
// 돌렸다" 는 착각으로 CI 시간이 늘거나(안전 방향이라 더 안 보인다), 반대로
|
|
423
|
+
// 단일 단계만 돌린 줄 알고 넘어간다. 값 부재도 같이 막는다(--port 규약 · 결정 240).
|
|
424
|
+
if (onlyIdx >= 0 && (onlyRaw === undefined || !knownSteps.includes(onlyRaw))) {
|
|
425
|
+
process.stderr.write(` ✗ gaon check: --only 값이 ${onlyRaw === undefined ? "없습니다" : `잘못됐습니다('${onlyRaw}')`}.\n` +
|
|
426
|
+
` → 지원 단계: ${knownSteps.join(" · ")}\n` +
|
|
427
|
+
` → 예: gaon check --only typecheck\n`);
|
|
428
|
+
process.exitCode = 2;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const only = onlyRaw !== undefined ? onlyRaw : undefined;
|
|
378
432
|
void runCheckCommand({
|
|
379
433
|
json: argv.includes("--json"),
|
|
380
434
|
only,
|
|
@@ -418,7 +472,7 @@ export function runCli(argv, opts = {}) {
|
|
|
418
472
|
});
|
|
419
473
|
return;
|
|
420
474
|
}
|
|
421
|
-
// `gaon doctor` — 정적 검사(M9-E ·
|
|
475
|
+
// `gaon doctor` — 정적 검사(M9-E · 29 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
|
|
422
476
|
// 선택 실행, --json 은 자동화 파싱용.
|
|
423
477
|
// exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
|
|
424
478
|
if (argv[0] === "doctor") {
|
|
@@ -437,9 +491,14 @@ export function runCli(argv, opts = {}) {
|
|
|
437
491
|
// --fix (dry-run): errors > 0 이면 1 · errors 0 이어도 **계획이 있으면 1**
|
|
438
492
|
// (고칠 게 있다는 신호 — 이전엔 warning-only 계획이 0 으로 삼켜짐).
|
|
439
493
|
// 기본: errors > 0 이면 1.
|
|
494
|
+
// 결정 410: `--fix --yes` 인데 **적용에 성공한 게 하나도 없으면** 비-0.
|
|
495
|
+
// 종전엔 잔여 errors 만 봐서, 위반이 warning 레벨이고 fixer 가 전부 실패하면
|
|
496
|
+
// (fixer 예외·대상 파일 선점 등) "고친 것 0" 인데 exit 0 이라 자동화가
|
|
497
|
+
// "고쳐졌다" 로 오판했다 — 시도했는데 아무것도 못 고친 건 성공이 아니다.
|
|
440
498
|
const fix = result.fix;
|
|
441
499
|
const dryPlans = fix !== undefined && !fix.applied && fix.outcomes.length > 0;
|
|
442
|
-
|
|
500
|
+
const nothingApplied = fix !== undefined && fix.applied && fix.outcomes.length > 0 && !fix.outcomes.some((o) => o.applied);
|
|
501
|
+
process.exitCode = result.errors.length > 0 || dryPlans || nothingApplied ? 1 : 0;
|
|
443
502
|
})
|
|
444
503
|
.catch((err) => {
|
|
445
504
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -505,21 +564,33 @@ export function runCli(argv, opts = {}) {
|
|
|
505
564
|
if (argv[0] === "db") {
|
|
506
565
|
const sub = argv[1];
|
|
507
566
|
const known = ["diff", "migrate", "reset", "seed", "status"];
|
|
567
|
+
// 결정 411: --json 모드면 실패 사유도 **구조화**해서 낸다 — runDbCommand 는 이미
|
|
568
|
+
// JSON 에러를 방출하는데(commands/db.ts) 라우팅 단계 실패만 텍스트라 자동화가
|
|
569
|
+
// 사유를 못 받았다(비대칭).
|
|
570
|
+
const dbJson = argv.includes("--json");
|
|
571
|
+
const failDb = (message) => {
|
|
572
|
+
if (dbJson) {
|
|
573
|
+
process.stdout.write(JSON.stringify({ ok: false, kind: "usage", command: "db", message }) + "\n");
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
process.stderr.write(` ✗ ${message}\n`);
|
|
577
|
+
}
|
|
578
|
+
process.exitCode = 1;
|
|
579
|
+
};
|
|
508
580
|
if (!sub || !known.includes(sub)) {
|
|
509
|
-
|
|
581
|
+
failDb(`알 수 없는 db 서브커맨드: ${sub ?? "(없음)"}\n` +
|
|
510
582
|
` → 지원: gaon db diff | migrate | reset | seed | status\n` +
|
|
511
|
-
` → 옵션: --json · --db <키>(생략 = 전 커넥션 순회) · --config <path> · --yes · --dry-run
|
|
512
|
-
process.exitCode = 1;
|
|
583
|
+
` → 옵션: --json · --db <키>(생략 = 전 커넥션 순회) · --config <path> · --yes · --dry-run`);
|
|
513
584
|
return;
|
|
514
585
|
}
|
|
515
|
-
// 결정 358: 미지
|
|
586
|
+
// 결정 358·404: 미지 플래그·미지 위치 인자·값 부재는 파싱 경계에서 fail-loud
|
|
587
|
+
// (오타 dry-run 이 실 적용되는 반전 방지).
|
|
516
588
|
let dbOpts;
|
|
517
589
|
try {
|
|
518
590
|
dbOpts = parseDbArgs(sub, argv);
|
|
519
591
|
}
|
|
520
592
|
catch (err) {
|
|
521
|
-
|
|
522
|
-
process.exitCode = 1;
|
|
593
|
+
failDb(err instanceof Error ? err.message : String(err));
|
|
523
594
|
return;
|
|
524
595
|
}
|
|
525
596
|
void runDbCommand(sub, dbOpts)
|
|
@@ -659,14 +730,27 @@ export function runCli(argv, opts = {}) {
|
|
|
659
730
|
// `gaon test [--scope unit|integration|all] [-- vitest 인자]` — 테스트 러너(M9-G).
|
|
660
731
|
if (argv[0] === "test") {
|
|
661
732
|
const rest = argv.slice(1);
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
733
|
+
// 결정 411: `--` 뒤는 **전부 vitest 몫**이다. 종전엔 gaon 이 위치와 무관하게
|
|
734
|
+
// --json·--scope 를 가로채 `gaon test -- --json`(vitest 자체 JSON 리포터)이
|
|
735
|
+
// 불가능했다 — 탈출구를 연다(help 에 적힌 계약과 코드를 일치시킨다).
|
|
736
|
+
const sepIdx = rest.indexOf("--");
|
|
737
|
+
const own = sepIdx >= 0 ? rest.slice(0, sepIdx) : rest;
|
|
738
|
+
const forwarded = sepIdx >= 0 ? rest.slice(sepIdx + 1) : [];
|
|
739
|
+
const scopeIdx = own.indexOf("--scope");
|
|
740
|
+
const scopeRaw = scopeIdx >= 0 ? own[scopeIdx + 1] : undefined;
|
|
741
|
+
// 결정 411: scope 오타를 조용히 전체 실행으로 폴백하지 않는다(--pm 오타를 막은
|
|
742
|
+
// 결정 167 과 대칭) — 통합만 돌리려던 CI 가 전체를 돌리며 실 인프라까지 띄운다.
|
|
743
|
+
if (scopeIdx >= 0 && scopeRaw !== "unit" && scopeRaw !== "integration" && scopeRaw !== "all") {
|
|
744
|
+
process.stderr.write(` ✗ gaon test: --scope 값이 ${scopeRaw === undefined ? "없습니다" : `잘못됐습니다('${scopeRaw}')`}.\n` +
|
|
745
|
+
` → 지원 값: unit · integration · all\n` +
|
|
746
|
+
` → 예: gaon test --scope integration\n`);
|
|
747
|
+
process.exitCode = 2;
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const scope = scopeRaw !== undefined ? scopeRaw : undefined;
|
|
667
751
|
const passthrough = [];
|
|
668
|
-
for (let i = 0; i <
|
|
669
|
-
const a =
|
|
752
|
+
for (let i = 0; i < own.length; i++) {
|
|
753
|
+
const a = own[i];
|
|
670
754
|
if (a === "--scope") {
|
|
671
755
|
i++;
|
|
672
756
|
continue;
|
|
@@ -676,7 +760,8 @@ export function runCli(argv, opts = {}) {
|
|
|
676
760
|
if (a !== undefined)
|
|
677
761
|
passthrough.push(a);
|
|
678
762
|
}
|
|
679
|
-
|
|
763
|
+
passthrough.push(...forwarded);
|
|
764
|
+
void runTestCommand(passthrough, { json: own.includes("--json"), scope })
|
|
680
765
|
.then((code) => {
|
|
681
766
|
process.exitCode = code;
|
|
682
767
|
})
|
package/dist/mcp/tools.js
CHANGED
|
@@ -603,6 +603,10 @@ export const TOOLS = [
|
|
|
603
603
|
description: '지정 시 그 검사 하나만 실행. 생략 시 전체.',
|
|
604
604
|
},
|
|
605
605
|
noDoctor: { type: 'boolean', description: 'true 면 doctor 를 제외(기본 false — doctor 는 기본 포함 · 결정 157).' },
|
|
606
|
+
// 결정 411: 폐기된 includeDoctor 를 스키마에 되살리지 **않는다**. 자리를 남기면
|
|
607
|
+
// strict 클라이언트의 구 호출이 "성공했는데 조용히 무시" 로 끝나 결정 362 가
|
|
608
|
+
// 없애려던 유령 파라미터 오도가 되살아난다 — 스키마 부재로 인한 검증 거부가
|
|
609
|
+
// 오히려 fail-loud(어느 파라미터를 쓰라는 신호)라 결정 362 계약을 유지한다.
|
|
606
610
|
},
|
|
607
611
|
additionalProperties: false,
|
|
608
612
|
},
|
package/dist/messages-gen.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 카탈로그의 키를 유니온 타입으로 물성화해, t('key') 의 존재하지 않는 키를 컴파일
|
|
5
5
|
// 타임에 잡는다(현재는 GaonMessages 가 비어 있어 키가 string 으로 열림). 생성 파일은
|
|
6
6
|
// 타입만 담는다(규칙 3). @gaonjs/i18n 의 공개 API(loadLocales·renderMessagesDts)만 쓴다.
|
|
7
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { dirname } from 'node:path';
|
|
9
9
|
import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
|
|
10
10
|
/**
|
|
@@ -13,11 +13,19 @@ import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
|
|
|
13
13
|
* 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
|
|
14
14
|
*/
|
|
15
15
|
export function generateMessagesDts(localesDir, out, baseLng) {
|
|
16
|
-
|
|
16
|
+
// 결정 414: 카탈로그가 사라졌으면 **옛 생성물을 지운다** — 남겨두면 없어진 키의
|
|
17
|
+
// 유니온이 그대로 살아 t('없어진키') 가 계속 컴파일된다(제거를 못 잡는 사각).
|
|
18
|
+
if (!existsSync(localesDir)) {
|
|
19
|
+
if (existsSync(out))
|
|
20
|
+
rmSync(out, { force: true });
|
|
17
21
|
return false;
|
|
22
|
+
}
|
|
18
23
|
const resources = loadLocales(localesDir);
|
|
19
|
-
if (Object.keys(resources).length === 0)
|
|
24
|
+
if (Object.keys(resources).length === 0) {
|
|
25
|
+
if (existsSync(out))
|
|
26
|
+
rmSync(out, { force: true });
|
|
20
27
|
return false;
|
|
28
|
+
}
|
|
21
29
|
mkdirSync(dirname(out), { recursive: true });
|
|
22
30
|
// 결정 352: 기준 로케일 = config i18n.fallbackLng(호출자가 정적 분석으로 전달).
|
|
23
31
|
// 이전엔 항상 알파벳순 첫 로케일이라 컴파일 보증이 fallback 체인과 다른 로케일에
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* @param app 대상 앱 폴더(apps/<app>/).
|
|
19
19
|
*/
|
|
20
20
|
export function controllerScaffold(names, app) {
|
|
21
|
+
// 라우트 키·api() 키는 **앱 접두**를 포함한다(결정 55 · 'app:controller#action') —
|
|
22
|
+
// 접두 없는 'posts#count' 는 멀티앱에서 해상되지 않아 주석 그대로 복사하면 컴파일이 깨진다.
|
|
21
23
|
const { pascal, plural } = names;
|
|
22
24
|
const lines = [
|
|
23
25
|
`// ${plural} 컨트롤러 — gaon g controller (M9-B).`,
|
|
@@ -35,7 +37,7 @@ export function controllerScaffold(names, app) {
|
|
|
35
37
|
` },`,
|
|
36
38
|
``,
|
|
37
39
|
` // GET /${plural}/count.json — JSON 액션 (errata E-3).`,
|
|
38
|
-
` // 반환값 = 응답. api('${plural}#count') 클라이언트가 Serialized<> 로 받는다.`,
|
|
40
|
+
` // 반환값 = 응답. api('${app}:${plural}#count') 클라이언트가 Serialized<> 로 받는다.`,
|
|
39
41
|
` // this.params() 안전 규칙: 라우트 > body > query (errata E-3 §5.1).`,
|
|
40
42
|
` async count() {`,
|
|
41
43
|
` return { total: await ${pascal}.count() }`,
|
package/dist/scaffold/page.js
CHANGED
|
@@ -22,10 +22,12 @@ export function pageScaffold(pagePath, app) {
|
|
|
22
22
|
const segments = trimmed.split('/').filter(Boolean);
|
|
23
23
|
const last = segments[segments.length - 1];
|
|
24
24
|
const parent = segments[segments.length - 2] ?? last;
|
|
25
|
-
// 라우트 키 기본값 — 앱 접두(결정 55) + 폴더=리소스명(
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
25
|
+
// 라우트 키 기본값 — 앱 접두(결정 55) + 폴더=리소스명(컨트롤러 파일명) +
|
|
26
|
+
// 파일=액션명. 앱 네임스페이스가 있어야 멀티앱에서 전역 GaonRouteMap 충돌이 없다.
|
|
27
|
+
// camelCase 를 **평탄화하지 않는다**: 컨트롤러 파일명·액션명 관례가 camelCase 라
|
|
28
|
+
// (`blogPosts.ts` · `editForm`) 소문자로 뭉개면 `blogposts#editform` 처럼 존재하지
|
|
29
|
+
// 않는 키가 나와 pageProps 가 해상되지 않는다(gaon g page BlogPosts/EditForm 실측).
|
|
30
|
+
const routeKey = `${app}:${toCamel(parent)}#${toCamel(last)}`;
|
|
29
31
|
const filePath = `apps/${app}/pages/${trimmed}.vue`;
|
|
30
32
|
const lines = [
|
|
31
33
|
`<script setup lang="ts">`,
|
|
@@ -58,9 +58,11 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
58
58
|
1. **TypeScript 전용 · 함수/객체 스타일.** JS 파일 추가 금지, 클래스형·
|
|
59
59
|
데코레이터 금지 — `model()`·`controller()`·`job()`·`service()`·
|
|
60
60
|
`channel()` 함수형 API 만.
|
|
61
|
-
2. **파사드 import 만.** 프레임웍 심볼은 `gaonjs
|
|
62
|
-
`gaonjs
|
|
63
|
-
`gaonjs/
|
|
61
|
+
2. **파사드 import 만.** 프레임웍 심볼은 `gaonjs` 와 그 하위 경로에서
|
|
62
|
+
import 한다 — 전체 목록: `gaonjs`·`gaonjs/data`·`gaonjs/web`·
|
|
63
|
+
`gaonjs/vue`·`gaonjs/async`·`gaonjs/config`·`gaonjs/service`·
|
|
64
|
+
`gaonjs/mail`·`gaonjs/storage`·`gaonjs/i18n`·`gaonjs/env`·
|
|
65
|
+
`gaonjs/log`·`gaonjs/testing`. `@gaonjs/*`(내부 스코프)·
|
|
64
66
|
`@inertiajs/vue3`(어댑터 내부 의존)는 앱 코드에서 직접 import 금지.
|
|
65
67
|
(설치명 `gaonjs` · CLI 명령 `gaon` — errata E-1.)
|
|
66
68
|
3. **의존 방향 4규칙** (doctor 강제): ① 앱→`domain/` 허용 ②
|
|
@@ -111,7 +113,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
111
113
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
112
114
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
113
115
|
|
|
114
|
-
### 2.2 `gaon doctor` 검사
|
|
116
|
+
### 2.2 `gaon doctor` 검사 29종
|
|
115
117
|
|
|
116
118
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
117
119
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -141,6 +143,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
141
143
|
26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
|
|
142
144
|
27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
|
|
143
145
|
28. `render-return` — 액션이 `this.render`/`this.redirect`/`this.json` 을 호출만 하고 `return` 하지 않음 = 응답이 버려져 조용히 204(백지) — `return this.render(...)` 로 고치라 (결정 340 · 경고)
|
|
146
|
+
29. `channel-collision` — 두 앱이 **같은 이름의 채널**을 각각 정의 = **에러**. 채널 이름은 전역이다(브로드캐스트 subject `gaon.chan.<이름>`·프레즌스 키에 앱 프리픽스 없음) — 한 앱의 broadcast 가 다른 앱 연결로 팬아웃되고 접속자 목록이 병합되며, 두 정의의 `authorize` 가 갈리면 공개 쪽 규칙으로 메시지가 샌다. 앱마다 이름을 분리하거나(클라이언트 `useChannel` 인자도 함께), 일부러 공유하는 채널이면 정의를 `shared/channels/<이름>.ts` 하나에 두고 각 앱 채널 파일에서 재수출하라(재수출은 통과 · 정의 하나 = 인가 규칙 하나) — 잡·리스너의 동명 등록 throw(결정 271)와 같은 계열의 정적 검사 (`agents/realtime.md` §2)
|
|
144
147
|
|
|
145
148
|
## 3. 로직 배치 One Way 판단표
|
|
146
149
|
|
|
@@ -201,7 +204,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
201
204
|
```bash
|
|
202
205
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
|
|
203
206
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
204
|
-
gaon doctor # 정적 검사
|
|
207
|
+
gaon doctor # 정적 검사 29종 (§2.2)
|
|
205
208
|
```
|
|
206
209
|
|
|
207
210
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -212,6 +215,7 @@ gaon doctor # 정적 검사 28종 (§2.2)
|
|
|
212
215
|
| `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**serve·work·hub 자동 기동**·**코드 변경 감시·재시작** · 결정 211) |
|
|
213
216
|
| `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
|
|
214
217
|
| `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` · `g auth --app <앱> --public` = 비-web 앱에 공개 회원가입(`/registration/new`)을 opt-in(기본: web=공개·비-web=역할 게이트 · 결정 155) |
|
|
218
|
+
| `gaon g auth --jwt --app <앱>` | **API(JWT) 앱 변형** — 이 명령 **하나**가 앱 폴더째 만든다(토큰 컨트롤러 3종 + `strategy:'jwt'` app.config + `<APP>_JWT_SECRET` 시드 · 페이지·UI 킷·회원가입 없음). `gaon g app` 을 먼저 돌리지 않는다 — 이미 있는 app.config 는 자동 배선을 못 해 exit 1 이고, 쓰지 않는 Vue 프론트가 남는다. web 앱은 세션이 정본이라 `--jwt` 불가 (결정 337·338) |
|
|
215
219
|
| `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
|
|
216
220
|
| `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
|
|
217
221
|
| `gaon check` / `test` / `doctor` | 검증 루프 |
|
|
@@ -86,7 +86,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
88
|
gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
|
|
89
|
-
gaon doctor # 정적 검사
|
|
89
|
+
gaon doctor # 정적 검사 29종 (상세 AGENTS §2.2)
|
|
90
90
|
npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
|
|
91
91
|
```
|
|
92
92
|
|
|
@@ -18,12 +18,22 @@ RUN pnpm install --frozen-lockfile
|
|
|
18
18
|
FROM base AS build
|
|
19
19
|
COPY --from=deps /app/node_modules ./node_modules
|
|
20
20
|
COPY . .
|
|
21
|
+
# 결정 408: 브라우저에 실리는 공개 변수(VITE_*)는 **빌드 시점에 번들로 각인**된다 —
|
|
22
|
+
# 런타임 env(compose.prod.yaml)로는 못 바꾼다. 값이 필요하면 빌드 인자로 넘긴다:
|
|
23
|
+
# docker build --build-arg VITE_API_BASE=https://api.example.com .
|
|
24
|
+
# 넘기지 않으면 아래 .env.example 의 placeholder 가 그대로 각인되므로, 공개 변수를
|
|
25
|
+
# 쓰는 앱은 반드시 build-arg 로 주거나 .env.example 에 **공개 가능한 실값**을 둔다.
|
|
26
|
+
# (비밀은 VITE_ 로 만들지 말 것 — 번들은 누구나 읽는다.)
|
|
27
|
+
ARG VITE_VARS=""
|
|
21
28
|
# gaon build 는 .gaon/env.d.ts 생성에 .env 가 필수(결정 198)인데 .env 는 이미지에
|
|
22
29
|
# 넣지 않는다(.dockerignore) — .env.example 을 임시 복제해 빌드하고 즉시 지운다.
|
|
23
30
|
# 지우는 이유: example 의 placeholder(SESSION_SECRET 등)가 런타임에 남으면 compose
|
|
24
31
|
# 가 env 를 안 넘겼을 때 fail-loud 검증을 조용히 통과시킨다(결정 313). 런타임 env
|
|
25
32
|
# 는 compose.prod.yaml 의 environment 가 단일 소스다.
|
|
26
|
-
RUN cp .env.example .env
|
|
33
|
+
RUN cp .env.example .env \
|
|
34
|
+
&& for kv in $VITE_VARS; do printf '%s\n' "$kv" >> .env; done \
|
|
35
|
+
&& pnpm build \
|
|
36
|
+
&& rm -f .env
|
|
27
37
|
|
|
28
38
|
# 3) 런타임 — 소스 + 번들 + 의존을 그대로 실행.
|
|
29
39
|
FROM base AS runtime
|
|
@@ -39,6 +39,11 @@ await SendWelcomeMail.later(user.id) // domain/jobs/sendWelcomeMail.ts (§1)
|
|
|
39
39
|
|
|
40
40
|
```ts
|
|
41
41
|
// 파생 효과 — 커밋 뒤에만 나가야 하는 발행은 서비스 afterCommit (§4 아웃박스).
|
|
42
|
+
// domain/services/registerUser.ts
|
|
43
|
+
import { service, afterCommit } from 'gaonjs/service' // service·afterCommit 는 같은 서브패스
|
|
44
|
+
import { User } from '../models/User.js'
|
|
45
|
+
import { ResizeAvatar } from '../jobs/resizeAvatar.js'
|
|
46
|
+
|
|
42
47
|
export const RegisterUser = service(async (input: RegisterInput) => {
|
|
43
48
|
const user = await User.create(input)
|
|
44
49
|
afterCommit(() => ResizeAvatar.later(user.id)) // 커밋 성공 후에만 발행
|
|
@@ -89,18 +94,25 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
|
|
|
89
94
|
유추가 충돌한다. 이제 **등록 시 throw**(조용한 덮어쓰기 = 발행이 엉뚱한
|
|
90
95
|
핸들러로 가던 무신호 버그 봉합) — 각각 `name` 을 다르게 주거나 파일을 나눈다.
|
|
91
96
|
리스너(`on()`)도 동형 — 같은 파일에 여럿 두면 `id` 를 다르게 준다(durable 충돌).
|
|
92
|
-
-
|
|
93
|
-
`
|
|
97
|
+
- **옵션(`JobOptions` 전부)** — `name` · `queue`(기본 `'default'`) · `retries`(기본 3) ·
|
|
98
|
+
`concurrency`(기본 1) + 백오프(`curve` 곡선 ms · `jitter` 기본 0.2). 이 6개가 전부다 —
|
|
99
|
+
**`maxDeliver` 는 잡 옵션이 아니라 워커 옵션**이다(아래).
|
|
94
100
|
- **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
|
|
95
101
|
`gaon jobs retry <id>` 로 조회·재적재한다(조회는 전량 배치 스캔 — 옛 레코드도
|
|
96
102
|
상한 없이 찾아 재적재할 수 있다 · 결정 351).
|
|
97
103
|
- **네이티브 재전달 소진도 DLQ 로 간다(결정 347).** 크래시 루프·미등록 잡(워커에
|
|
98
|
-
`domain/jobs/` 파일이 배포되지 않음)이 재전달 상한(기본 25
|
|
104
|
+
`domain/jobs/` 파일이 배포되지 않음)이 재전달 상한(기본 25)을
|
|
99
105
|
소진하면, 워커가 MAX_DELIVERIES advisory 를 받아 그 잡을 DLQ 로 이관한다 —
|
|
100
106
|
이전엔 스트림에 무신호로 영구 잔류했다. 미등록 잡의 재전달 지연은 지수
|
|
101
107
|
(1s→2s→…30s 포화)이고 잡 이름당 1회 경고를 남긴다(정상 롤링 배포 창은 통과).
|
|
102
108
|
advisory 는 비영속이라 백스톱은 best-effort 다(소진 순간 워커가 전무하면 다음
|
|
103
109
|
소진 때 회수).
|
|
110
|
+
- **`maxDeliver` 는 워커 옵션이다 — 잡별로 못 준다.** 재전달 상한은 `runWork()` 의
|
|
111
|
+
`maxDeliver`(= `runWorker()` 로 전달 · `packages/async/src/worker.ts`)이고 그 워커가
|
|
112
|
+
소비하는 **모든 큐에 공통**으로 걸린다. `job(handler, { maxDeliver: … })` 같은 표면은
|
|
113
|
+
없다(`JobOptions` 는 위 6개뿐 — 넘겨도 무시된다). 잡별로 조절 가능한 것은
|
|
114
|
+
`retries`(앱 레벨 재시도)뿐이고, `maxDeliver` 는 그 아래층인 **JetStream 네이티브
|
|
115
|
+
재전달**(크래시 복구·미등록 잡 백스톱)의 상한이라 축이 다르다.
|
|
104
116
|
- **큐 동시성은 큐별로 정확히 적용된다(결정 348)** — 다른 큐의 긴 잡이 이 큐의
|
|
105
117
|
처리량을 깎지 않는다(잡별 `concurrency` 선언 = 그 큐의 실제 동시 처리 수).
|
|
106
118
|
- **워커 복원력(결정 258)** — 재시도 재적재나 DLQ 이관을 하는 도중 NATS 가
|
|
@@ -190,8 +202,12 @@ await OrderPlaced.emit({ orderId: 1n })
|
|
|
190
202
|
|
|
191
203
|
- 실패하면 백오프(잡과 같은 곡선 `[1s, 5s, 30s, 5m, 1h]`)로 재전달되고, 최대
|
|
192
204
|
재전달(기본 6 · 최초 포함) 소진 시 **영구 폐기**된다. 즉 계속 실패하는 이벤트는
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
최초 실패로부터 **약 1시간 5분**(1s+5s+30s+5m+1h = **1h05m36s** · 지터 ±20% 별도)
|
|
206
|
+
뒤 사라진다 — `gaon work` 가 `✗ 이벤트 폐기` 로 신호한다
|
|
207
|
+
(결정 308 · 이전엔 human 모드 무신호). **크래시 루프**(핸들러 throw 가 아니라
|
|
208
|
+
프로세스가 ack 전에 반복 사망)로 소진돼도 MAX_DELIVERIES advisory 백스톱이
|
|
209
|
+
같은 `✗ 이벤트 폐기` 신호를 낸다(결정 398 · 잡의 결정 347 동형 · advisory 는
|
|
210
|
+
비영속이라 best-effort). 놓치면 안 되는 처리는 리스너에서 잡을
|
|
195
211
|
발행해(`.later()`) 잡의 재시도·DLQ 배터리로 넘긴다.
|
|
196
212
|
- **리스너별 순차 처리는 보장되지 않는다** — 재시도 대기 중 다음 이벤트가 먼저
|
|
197
213
|
처리될 수 있고, 드물게 같은 리스너의 두 이벤트가 겹칠 수 있다. 순서·중복에
|
|
@@ -235,15 +251,20 @@ export const PlaceOrder = service(async (input: { name: string }) => {
|
|
|
235
251
|
어떤 경로로도 삭제되지 않으므로 유실이 없다.
|
|
236
252
|
- at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
|
|
237
253
|
흡수한다(claim 리스 60s < dedupe 창 120s 라 "발행 후 표시 전 크래시"
|
|
238
|
-
재발행도 창 안에서 접힌다).
|
|
254
|
+
재발행도 창 안에서 접힌다). **"claim 리스 < dedupe 창" 은 부팅이 강제한다
|
|
255
|
+
(결정 396)** — `dedupeWindowMs` 를 claim 아래로 줄이는 오설정은 조용한 이중
|
|
256
|
+
배달이 되므로 `gaon work` 가 수리 안내와 함께 fail-loud 한다. claim 리스는
|
|
257
|
+
env `GAON_OUTBOX_CLAIM_TIMEOUT_MS`(또는 `runWork` 의 `outboxClaimTimeoutMs`)
|
|
258
|
+
로 조정한다.
|
|
239
259
|
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
|
|
240
260
|
기동 시 보장된다(결정 144 · nats 설정이 있을 때).
|
|
241
261
|
- 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
|
|
242
|
-
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격·폴링
|
|
243
|
-
`GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS
|
|
262
|
+
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격·폴링 주기·claim 리스는 env
|
|
263
|
+
`GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`·
|
|
264
|
+
`GAON_OUTBOX_CLAIM_TIMEOUT_MS`(결정 396)
|
|
244
265
|
로 조정한다(결정 312 · `GAON_WORKER_*` 와 대칭 · `gaon work` 가 읽는다). 프로그래매틱
|
|
245
|
-
경로는 `runWork()` 의 `outboxRetentionMs`·`outboxPurgeIntervalMs`·`relayPollMs
|
|
246
|
-
미발행 행은 절대 삭제되지 않는다.
|
|
266
|
+
경로는 `runWork()` 의 `outboxRetentionMs`·`outboxPurgeIntervalMs`·`relayPollMs`·
|
|
267
|
+
`outboxClaimTimeoutMs`. 미발행 행은 절대 삭제되지 않는다.
|
|
247
268
|
- **발행 실패는 행 단위로 격리된다(결정 306 · 346).** 한 행의 publish 가 실패해도
|
|
248
269
|
(예: 페이로드가 NATS `max_payload` 1MiB 초과) 그 행만 claim 된 채 남아 리스
|
|
249
270
|
만료(60s) 후 재시도되고, 뒤 행들은 정상 발행된다 — 한 행이 아웃박스 전체를
|
|
@@ -301,7 +322,8 @@ export default schedule((s) => {
|
|
|
301
322
|
워커에 로드밸런싱된다. 발행은 1인, 처리는 N인.
|
|
302
323
|
- **exactly-once(발행 기준)** — 한 스케줄 틱은 리더 1인이 한 번만 발행한다.
|
|
303
324
|
리더 교체(페일오버) 순간 구·신 리더가 같은 틱을 겹쳐 발행해도, 결정론적
|
|
304
|
-
dedupe 키 + JetStream 중복 윈도우가 이를 1회로 수렴시킨다
|
|
325
|
+
dedupe 키 + JetStream 중복 윈도우가 이를 1회로 수렴시킨다 — cron 은 발화
|
|
326
|
+
분(minute) 키(결정 233), **every 는 위상 슬롯 키**(결정 394 · 아래)로 접힌다.
|
|
305
327
|
잡 자체는 재시도(백오프)가 있으니 **핸들러는 멱등**하게 짠다(같은 잡이 두 번
|
|
306
328
|
처리돼도 안전하게).
|
|
307
329
|
- **`s.every` 위상은 리더 교체를 가로질러 보존된다(결정 349).** 마지막 발화
|
|
@@ -309,6 +331,13 @@ export default schedule((s) => {
|
|
|
309
331
|
아니면 잔여 시간만 기다린다 — 재선출마다 타이머가 리셋돼 리스 플래핑이
|
|
310
332
|
interval 보다 잦으면 every 잡이 영영 안 돌던 기아가 없다. 리스 갱신도 순단
|
|
311
333
|
1~2회는 재시도 후에만 리더를 내려놓는다(불필요한 failover·리셋 억제).
|
|
334
|
+
- **`s.every` 의 failover 이중발화도 접힌다(결정 394).** 구 리더가 발화 직후
|
|
335
|
+
위상 기록(KV put · best-effort)을 못 남기고 죽으면 신 리더가 같은 슬롯을
|
|
336
|
+
즉시 재발화하는데, every 발화가 위상 슬롯 기반 결정적 dedupe 키
|
|
337
|
+
(`enqueueScheduled`)를 쓰므로 같은 슬롯의 재발화는 JetStream 중복 윈도우
|
|
338
|
+
안에서 1건으로 수렴한다 — 결정 349 이후 every 만 랜덤 id(`later()`)라
|
|
339
|
+
안 접히던 창을 닫았다. 사임(stop) 시 리스 키 삭제도 CAS 라 stale 리더의
|
|
340
|
+
종료가 신 리더의 키를 지우지 않는다(결정 395).
|
|
312
341
|
- **`gaon serve` 는 스케줄러를 돌리지 않는다** — 스케줄·리더 선출·아웃박스
|
|
313
342
|
릴레이는 **`gaon work` 전용**이다. 웹 프로세스는 잡을 **발행**만 할 수 있고
|
|
314
343
|
(`.later()`), 처리·스케줄은 워커가 한다. **운영에서** 스케줄이 안 도는 흔한
|
|
@@ -352,6 +381,9 @@ drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤
|
|
|
352
381
|
- **`gaon work`** — 신규 잡 pull 을 멈추고 **진행 중 잡을 완료**한 뒤 종료한다
|
|
353
382
|
(`drainTimeoutMs` 상한 · 기본 30s). 스케줄러 리더는 즉시 반납해 다른 인스턴스가
|
|
354
383
|
승계한다. drain 상한을 넘긴 잡은 ack 되지 않아 재전달(크래시 복구)된다.
|
|
384
|
+
상한은 **큐가 동시성 포화 상태로 긴 잡을 물고 있어도** 지켜진다(결정 397 —
|
|
385
|
+
종전엔 이 경우 소비 루프 종료 대기가 상한 밖이라 stop 이 잡 완료까지 무기한
|
|
386
|
+
붙들렸다).
|
|
355
387
|
- **컨테이너 기본 워커 1** — `gaon serve` 클러스터(`--workers`)도 SIGTERM 에
|
|
356
388
|
워커들을 graceful drain 후 종료한다(결정 84).
|
|
357
389
|
|
|
@@ -457,12 +489,16 @@ async create() {
|
|
|
457
489
|
- **스케줄 대상은 항상 잡 · 무인자** — `s.every('10m', async () => ...)` 인라인
|
|
458
490
|
함수 금지. 인자 필수 잡은 컴파일 에러로 거부된다(결정 310 · §5).
|
|
459
491
|
- **계속 실패하는 리스너는 이벤트를 잃는다** — 리스너는 DLQ 가 없어 재전달
|
|
460
|
-
소진(기본 6회 · 약
|
|
492
|
+
소진(기본 6회 · 약 1h05m36s) 후 영구 폐기된다(§3 계약 · `gaon work` 가 `✗ 이벤트
|
|
461
493
|
폐기` 로 신호). 유실 불가 처리는 리스너에서 잡으로 넘긴다.
|
|
462
494
|
- **커밋 전 발행 주의** — 트랜잭션 안에서 DB 확정 후에만 나가야 하는
|
|
463
495
|
발행은 `afterCommit()` 또는 아웃박스로.
|
|
464
496
|
- **테스트에서 NATS 목업 금지** (§9) — 실 JetStream 에 접속한다
|
|
465
497
|
(`agents/testing.md`).
|
|
498
|
+
- **MySQL 은 8.0+(`explicit_defaults_for_timestamp=ON`) 전제** (결정 400) —
|
|
499
|
+
구식 설정(OFF · 5.7 기본)에서는 아웃박스 테이블의 timestamp 컬럼이 NOT NULL
|
|
500
|
+
+ zero-date 기본으로 생성돼 strict `NO_ZERO_DATE` 와 충돌할 수 있다. 관리형
|
|
501
|
+
MySQL 에서 해당 플래그가 OFF 면 ON 으로 바꿔라.
|
|
466
502
|
- **동시 실행 방지에 로컬 뮤텍스·플래그 금지** (결정 147) — `let running = false`
|
|
467
503
|
같은 프로세스 로컬 가드는 멀티 인스턴스에서 안 먹는다. `lock(key, fn)` 을
|
|
468
504
|
쓴다. 운영에서 `redis` 미설정이면 `lock()` 이 수리 안내로 throw 하니 조용한
|
|
@@ -485,12 +521,18 @@ async create() {
|
|
|
485
521
|
| 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
|
|
486
522
|
| 결정 258 | 워커 소비 루프 복원력(§1) — 재시도/DLQ 발행이 NATS 순단으로 실패해도 큐 소비가 멈추지 않음(nak 재전달 백스톱 · 잡 유실 방지 · 실패 로그) |
|
|
487
523
|
| 결정 306 | 아웃박스 발행 실패 행 격리(§4) — poison 행(페이로드 상한 초과 등)이 배치 전체를 세우지 않음 · 실패 행은 미발행 유지(유실 없음·재시도) · `relay-error` 이벤트로 관측 |
|
|
488
|
-
| 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ
|
|
524
|
+
| 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ 없음 · 곡선 합 **1h05m36s** · 종전 "1h36m" 오기 정정) 명문화 |
|
|
489
525
|
| 결정 310 | 스케줄 대상 잡 무인자 가드(§5) — 인자 필수 잡 등록을 컴파일 타임 거부(메서드 bivariance 로 통과해 `undefined` 인자 발화하던 구멍 차단) |
|
|
490
526
|
| 결정 312 | 아웃박스 릴레이 env 튜닝(§4) — `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`(`GAON_WORKER_*` 대칭) |
|
|
491
527
|
| 결정 346 | 아웃박스 2단계 publish(§4) — claim(`claimed_at`+SKIP LOCKED 짧은 tx) → 커밋 → tx 밖 publish → 표시 · NATS 지연의 DB 락 전파 제거 · claim 리스 60s(< dedupe 창) · 유실 0 |
|
|
492
|
-
| 결정 347 | max_deliver 소진 DLQ 백스톱(§1) — MAX_DELIVERIES advisory → DLQ 이관(무신호 영구 잔류 봉합) · 미등록 잡 지수 nak(1s→30s 포화)+이름당 1회 경고 · `maxDeliver` 옵션 |
|
|
528
|
+
| 결정 347 | max_deliver 소진 DLQ 백스톱(§1) — MAX_DELIVERIES advisory → DLQ 이관(무신호 영구 잔류 봉합) · 미등록 잡 지수 nak(1s→30s 포화)+이름당 1회 경고 · 상한은 **워커 옵션** `runWork({ maxDeliver })`(기본 25 · 잡 옵션 아님 · 워커의 전 큐 공통) |
|
|
493
529
|
| 결정 348 | 워커 큐별 동시성 게이트(§1) — 전역 inflight 비교가 낳던 교차 큐 간섭 제거(선언 `concurrency` = 실제 동시 처리) |
|
|
494
530
|
| 결정 349 | 리스 갱신 순단 재시도 + every 위상 KV 보존(§5) — 키가 내 것이면 revision 동기화 재시도 후에만 revoke · `gaon_scheduler` KV 로 위상 이어받기(플래핑 기아 봉합) |
|
|
495
531
|
| 결정 351 | DLQ 조회 배치 스캔(§1) — ordered 컨슈머 fetch 로 삭제 갭 서버 스킵 · findDlq 1000건 상한 제거(옛 레코드 retry 복원) |
|
|
532
|
+
| 결정 394 | every failover 이중발화 dedupe(§5) — 위상 슬롯 기반 결정적 키(`enqueueScheduled`)로 구·신 리더의 같은 슬롯 재발화가 1건으로 수렴(결정 349 가 연 창 봉합 · cron 결정 233 동형) |
|
|
533
|
+
| 결정 395 | 리스 사임 CAS 삭제(§5) — stop() 이 `previousSeq` 로 자기 revision 에서만 키 삭제 · stale 리더 종료가 신 리더 키를 지우던 재선출 순단 봉합(허브 endpoint 결정 259 동형) |
|
|
534
|
+
| 결정 396 | 아웃박스 claim<dedupe 불변식 부팅 강제(§4) — 위반 시 fail-loud(조용한 이중 배달 봉합) · claim 리스 운영 표면 `GAON_OUTBOX_CLAIM_TIMEOUT_MS`/`outboxClaimTimeoutMs` 신설 |
|
|
535
|
+
| 결정 397 | graceful drain 상한 유계화(§6) — 포화 큐의 긴 잡이 소비 루프 종료를 붙들어 `drainTimeoutMs` 가 무효이던 우회 봉합(closers 도 같은 deadline 공유 · 워커·리스너 공통) |
|
|
536
|
+
| 결정 398 | 리스너 크래시 루프 소진 신호(§3) — EVENTS 스트림 MAX_DELIVERIES advisory 백스톱으로 `dropped` 신호(잡 결정 347 동형 · 공유 스트림이라 메시지 삭제는 안 함) |
|
|
537
|
+
| 결정 400 | P3 청소 묶음 — 허브 KV 복원 오염 키 방어(try/continue) · 라인 디코더 완결 초과 라인도 onOverflow(무신호 명령 소실 금지) · MySQL timestamp 모드 함정 문서화 · listDlq 롤링 버퍼(O(limit) 메모리) · advisory dead 는 삭제 성공 워커만 신호 + 삭제 실패 잔류 관측 |
|
|
496
538
|
| §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
|