@gaonjs/cli 0.1.6 → 0.2.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/db.d.ts +20 -0
- package/dist/db.js +62 -0
- package/dist/doctor.d.ts +36 -0
- package/dist/doctor.js +218 -0
- package/dist/domain.d.ts +12 -0
- package/dist/domain.js +85 -0
- package/dist/hub.d.ts +5 -3
- package/dist/hub.js +6 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +64 -0
- package/dist/jobs.d.ts +7 -0
- package/dist/jobs.js +76 -0
- package/dist/work.d.ts +24 -0
- package/dist/work.js +122 -0
- package/package.json +7 -5
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type SeedDef } from '@gaonjs/data';
|
|
2
|
+
export interface DbSeedOptions {
|
|
3
|
+
readonly json?: boolean;
|
|
4
|
+
/** 도메인 루트(domain/ 의 부모). 기본 cwd. */
|
|
5
|
+
readonly root?: string;
|
|
6
|
+
/** DB URL 오버라이드. 생략 시 env GAON_DATABASE_URL. */
|
|
7
|
+
readonly databaseUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
/** domain/seed.ts 의 default export 를 읽어 SeedDef 를 돌려준다(없으면 안내 에러). */
|
|
10
|
+
export declare function loadSeed(root: string): Promise<SeedDef>;
|
|
11
|
+
export interface DbSeedResult {
|
|
12
|
+
readonly exitCode: number;
|
|
13
|
+
readonly text: string;
|
|
14
|
+
readonly json: unknown;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
|
|
18
|
+
* 커넥션 정리(destroyAllConnections)는 항상 수행한다.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runDbSeedCommand(opts?: DbSeedOptions): Promise<DbSeedResult>;
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @gaonjs/cli · `gaon db seed` (§7 M8 — domain/seed.ts 실행)
|
|
2
|
+
//
|
|
3
|
+
// 관례: `domain/seed.ts` 의 default export(seed 정의)를 실행한다. 커넥션은
|
|
4
|
+
// `gaon work` 와 **같은 관례**로 연결한다 — `GAON_DATABASE_URL` 로 main
|
|
5
|
+
// 커넥션을 세운다(§4.5 멀티 커넥션의 gaon.config.ts 전체 로더는 M9 CLI 완성).
|
|
6
|
+
// 모든 명령은 --json 을 함께 낸다(CLAUDE.md §4).
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { env, EnvError } from '@gaonjs/core';
|
|
11
|
+
import { createDb, registerConnection, destroyAllConnections, isSeedDef, } from '@gaonjs/data';
|
|
12
|
+
import { registerTsResolve } from './tsResolve.js';
|
|
13
|
+
/** DB URL 에서 어댑터를 추정한다(gaon work 와 동일 규칙). */
|
|
14
|
+
function dbConfigFromUrl(url) {
|
|
15
|
+
if (url.startsWith('mysql://') || url.startsWith('mariadb://')) {
|
|
16
|
+
return { adapter: 'mysql', url, poolMax: 4 };
|
|
17
|
+
}
|
|
18
|
+
return { adapter: 'postgres', url, poolMax: 4 };
|
|
19
|
+
}
|
|
20
|
+
/** domain/seed.ts 의 default export 를 읽어 SeedDef 를 돌려준다(없으면 안내 에러). */
|
|
21
|
+
export async function loadSeed(root) {
|
|
22
|
+
const file = join(root, 'domain', 'seed.ts');
|
|
23
|
+
if (!existsSync(file)) {
|
|
24
|
+
throw new Error(`시드 파일이 없습니다: domain/seed.ts\n` +
|
|
25
|
+
` → domain/seed.ts 를 만들고 default export 로 시드를 선언하세요:\n` +
|
|
26
|
+
` import { seed } from 'gaonjs/data'\n` +
|
|
27
|
+
` export default seed(async () => { /* await Model.create(...) */ })`);
|
|
28
|
+
}
|
|
29
|
+
registerTsResolve();
|
|
30
|
+
const mod = (await import(pathToFileURL(file).href));
|
|
31
|
+
if (!isSeedDef(mod.default)) {
|
|
32
|
+
throw new Error(`domain/seed.ts 의 default export 가 시드 정의가 아닙니다.\n` +
|
|
33
|
+
` → seed(...) 로 감싸 default export 하세요: export default seed(async () => {...})`);
|
|
34
|
+
}
|
|
35
|
+
return mod.default;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
|
|
39
|
+
* 커넥션 정리(destroyAllConnections)는 항상 수행한다.
|
|
40
|
+
*/
|
|
41
|
+
export async function runDbSeedCommand(opts = {}) {
|
|
42
|
+
const root = opts.root ?? process.cwd();
|
|
43
|
+
const dbUrl = opts.databaseUrl ?? env.optional('GAON_DATABASE_URL');
|
|
44
|
+
if (!dbUrl) {
|
|
45
|
+
throw new EnvError(`환경변수 GAON_DATABASE_URL 가 설정되지 않았습니다(시드에 DB 연결 필요).\n` +
|
|
46
|
+
` → .env 에 다음 줄을 추가하고 다시 실행하세요:\n` +
|
|
47
|
+
` GAON_DATABASE_URL=postgres://user:pass@localhost:5432/mydb`);
|
|
48
|
+
}
|
|
49
|
+
registerConnection('main', createDb(dbConfigFromUrl(dbUrl)), dbConfigFromUrl(dbUrl).adapter);
|
|
50
|
+
try {
|
|
51
|
+
const def = await loadSeed(root);
|
|
52
|
+
await def.run();
|
|
53
|
+
return {
|
|
54
|
+
exitCode: 0,
|
|
55
|
+
text: ` gaon db seed · 완료 — domain/seed.ts 실행됨`,
|
|
56
|
+
json: { command: 'seed', ok: true },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await destroyAllConnections();
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ResponseKind = 'render' | 'redirect' | 'json' | 'plain';
|
|
2
|
+
export interface ActionUsage {
|
|
3
|
+
readonly file: string;
|
|
4
|
+
readonly action: string;
|
|
5
|
+
readonly line: number;
|
|
6
|
+
readonly kinds: readonly ResponseKind[];
|
|
7
|
+
}
|
|
8
|
+
export interface DoctorIssue {
|
|
9
|
+
readonly file: string;
|
|
10
|
+
readonly line: number;
|
|
11
|
+
readonly action: string;
|
|
12
|
+
readonly kinds: readonly ResponseKind[];
|
|
13
|
+
readonly message: string;
|
|
14
|
+
}
|
|
15
|
+
export interface DoctorResult {
|
|
16
|
+
readonly ok: boolean;
|
|
17
|
+
readonly checked: number;
|
|
18
|
+
readonly issues: readonly DoctorIssue[];
|
|
19
|
+
}
|
|
20
|
+
/** 컨트롤러 파일 하나를 검사한다. */
|
|
21
|
+
export declare function inspectControllerSource(file: string, source: string): ActionUsage[];
|
|
22
|
+
/**
|
|
23
|
+
* apps/<app>/controllers/ 를 훑어 응답 혼용 액션을 찾는다. 액션 하나의 kinds
|
|
24
|
+
* 배열이 2종 이상이면 위반(단, 'plain' 하나만 있는 것과 render 만 있는 것은 통과).
|
|
25
|
+
*
|
|
26
|
+
* 명시적 예외: 반환 없는 return(=undefined→204)만 있는 경우는 통과 — void 액션은
|
|
27
|
+
* 흔히 혼용 없이 정상적으로 쓰인다.
|
|
28
|
+
*/
|
|
29
|
+
export declare function runDoctor(appsDir: string): Promise<DoctorResult>;
|
|
30
|
+
export declare function fileExists(file: string): Promise<boolean>;
|
|
31
|
+
export interface DoctorCommandOptions {
|
|
32
|
+
readonly cwd?: string;
|
|
33
|
+
readonly json?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** `gaon doctor` 진입점. */
|
|
36
|
+
export declare function runDoctorCommand(opts?: DoctorCommandOptions): Promise<number>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gaonjs/cli · `gaon doctor` — 정적 검사 (M3 · errata E-3)
|
|
3
|
+
*
|
|
4
|
+
* v1 첫 검사 항목: **응답 혼용 금지**. 한 컨트롤러 액션은 render/JSON/
|
|
5
|
+
* redirect 중 한 가지 응답 형태만 낼 수 있다(§C · The One Way). 조건 분기로
|
|
6
|
+
* 섞으면 "이 라우트가 무엇을 돌려주는지"가 코드를 읽어야 알게 돼 AI 첫 시도
|
|
7
|
+
* 성공률이 떨어진다.
|
|
8
|
+
*
|
|
9
|
+
* 검사 방법(정적 분석):
|
|
10
|
+
* · apps/<app>/controllers/*.ts 를 TS AST 로 파싱.
|
|
11
|
+
* · export default controller({ ... }) 의 액션 함수마다 return 노드에서
|
|
12
|
+
* 최종 응답 형태를 분류(render | redirect | json | plain).
|
|
13
|
+
* · 서로 다른 형태가 두 종류 이상이면 위반.
|
|
14
|
+
* · this.render/this.redirect/this.json/평범한 객체 반환의 4종을 구별한다.
|
|
15
|
+
*
|
|
16
|
+
* 위반 시: 파일·라인·액션 이름 + 감지된 형태 목록 + "→ 한 액션은 한 응답
|
|
17
|
+
* 형태만 유지하세요(errata E-3 §C)" 안내를 낸다.
|
|
18
|
+
*
|
|
19
|
+
* 나머지 doctor 검사(의존 방향 §1-5, 커넥션 §4.5 등)는 후속 마일스톤에서
|
|
20
|
+
* 이 모듈에 규칙을 얹는다.
|
|
21
|
+
*/
|
|
22
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
23
|
+
import { join, relative, resolve } from 'node:path';
|
|
24
|
+
import ts from 'typescript';
|
|
25
|
+
/** 컨트롤러 파일 하나를 검사한다. */
|
|
26
|
+
export function inspectControllerSource(file, source) {
|
|
27
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
28
|
+
const usages = [];
|
|
29
|
+
const visit = (node) => {
|
|
30
|
+
if (ts.isCallExpression(node) && isControllerCall(node)) {
|
|
31
|
+
const arg = node.arguments[0];
|
|
32
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
33
|
+
for (const prop of arg.properties) {
|
|
34
|
+
const action = actionName(prop);
|
|
35
|
+
if (!action)
|
|
36
|
+
continue;
|
|
37
|
+
const body = actionBody(prop);
|
|
38
|
+
if (!body)
|
|
39
|
+
continue;
|
|
40
|
+
const kinds = classifyReturns(body);
|
|
41
|
+
const { line } = sf.getLineAndCharacterOfPosition(prop.getStart(sf));
|
|
42
|
+
usages.push({ file, action, line: line + 1, kinds });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
ts.forEachChild(node, visit);
|
|
47
|
+
};
|
|
48
|
+
visit(sf);
|
|
49
|
+
return usages;
|
|
50
|
+
}
|
|
51
|
+
function isControllerCall(node) {
|
|
52
|
+
const e = node.expression;
|
|
53
|
+
if (ts.isIdentifier(e) && e.text === 'controller')
|
|
54
|
+
return true;
|
|
55
|
+
if (ts.isPropertyAccessExpression(e) && e.name.text === 'controller')
|
|
56
|
+
return true;
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
function actionName(prop) {
|
|
60
|
+
if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name))
|
|
61
|
+
return prop.name.text;
|
|
62
|
+
if (ts.isPropertyAssignment(prop) &&
|
|
63
|
+
ts.isIdentifier(prop.name) &&
|
|
64
|
+
(ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
|
|
65
|
+
return prop.name.text;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
function actionBody(prop) {
|
|
70
|
+
if (ts.isMethodDeclaration(prop))
|
|
71
|
+
return prop.body;
|
|
72
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
73
|
+
if (ts.isArrowFunction(prop.initializer))
|
|
74
|
+
return prop.initializer.body;
|
|
75
|
+
if (ts.isFunctionExpression(prop.initializer))
|
|
76
|
+
return prop.initializer.body;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/** 액션 본문의 모든 return 표현식을 응답 형태로 분류하고 중복을 제거해 반환한다. */
|
|
81
|
+
function classifyReturns(body) {
|
|
82
|
+
const kinds = new Set();
|
|
83
|
+
const classify = (expr) => {
|
|
84
|
+
// await x → x 로 되짚어 본다.
|
|
85
|
+
if (ts.isAwaitExpression(expr))
|
|
86
|
+
return classify(expr.expression);
|
|
87
|
+
// this.render / this.redirect / this.json
|
|
88
|
+
if (ts.isCallExpression(expr)) {
|
|
89
|
+
const c = expr.expression;
|
|
90
|
+
if (ts.isPropertyAccessExpression(c) && c.expression.kind === ts.SyntaxKind.ThisKeyword) {
|
|
91
|
+
if (c.name.text === 'render')
|
|
92
|
+
return 'render';
|
|
93
|
+
if (c.name.text === 'redirect')
|
|
94
|
+
return 'redirect';
|
|
95
|
+
if (c.name.text === 'json')
|
|
96
|
+
return 'json';
|
|
97
|
+
}
|
|
98
|
+
// 삼항·논리 연산 결과라도 호출식이 나오면 함수 반환값에 의존 — 보수적으로 plain.
|
|
99
|
+
return 'plain';
|
|
100
|
+
}
|
|
101
|
+
// 삼항 연산자 — 양쪽 각각 분류
|
|
102
|
+
if (ts.isConditionalExpression(expr)) {
|
|
103
|
+
kinds.add(classify(expr.whenTrue));
|
|
104
|
+
kinds.add(classify(expr.whenFalse));
|
|
105
|
+
return classify(expr.whenTrue); // 반환값(대표) — Set 에 이미 다 넣음
|
|
106
|
+
}
|
|
107
|
+
// 그 외(객체·배열·literal·식별자 등) → plain(JSON 액션)
|
|
108
|
+
return 'plain';
|
|
109
|
+
};
|
|
110
|
+
const visit = (node) => {
|
|
111
|
+
if (ts.isReturnStatement(node)) {
|
|
112
|
+
if (node.expression)
|
|
113
|
+
kinds.add(classify(node.expression));
|
|
114
|
+
else
|
|
115
|
+
kinds.add('plain'); // 빈 return 은 undefined → 204 (plain 으로 취급, 혼용 검사에서만 의미)
|
|
116
|
+
return; // return 안의 하위 함수는 다른 스코프 — 이 return 만 잡는다
|
|
117
|
+
}
|
|
118
|
+
// 중첩 함수/화살표는 다른 스코프 — 파고들지 않는다.
|
|
119
|
+
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node))
|
|
120
|
+
return;
|
|
121
|
+
ts.forEachChild(node, visit);
|
|
122
|
+
};
|
|
123
|
+
// 표현식 본문 화살표(=> render(...))는 body 가 표현식.
|
|
124
|
+
if (ts.isBlock(body))
|
|
125
|
+
visit(body);
|
|
126
|
+
else
|
|
127
|
+
kinds.add(classify(body));
|
|
128
|
+
return [...kinds];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* apps/<app>/controllers/ 를 훑어 응답 혼용 액션을 찾는다. 액션 하나의 kinds
|
|
132
|
+
* 배열이 2종 이상이면 위반(단, 'plain' 하나만 있는 것과 render 만 있는 것은 통과).
|
|
133
|
+
*
|
|
134
|
+
* 명시적 예외: 반환 없는 return(=undefined→204)만 있는 경우는 통과 — void 액션은
|
|
135
|
+
* 흔히 혼용 없이 정상적으로 쓰인다.
|
|
136
|
+
*/
|
|
137
|
+
export async function runDoctor(appsDir) {
|
|
138
|
+
const issues = [];
|
|
139
|
+
let checked = 0;
|
|
140
|
+
const apps = await safeListDirs(appsDir);
|
|
141
|
+
for (const app of apps) {
|
|
142
|
+
const ctrlDir = join(appsDir, app, 'controllers');
|
|
143
|
+
for (const file of await safeListFiles(ctrlDir)) {
|
|
144
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
|
|
145
|
+
continue;
|
|
146
|
+
const full = join(ctrlDir, file);
|
|
147
|
+
const source = await readFile(full, 'utf8');
|
|
148
|
+
const usages = inspectControllerSource(full, source);
|
|
149
|
+
checked += usages.length;
|
|
150
|
+
for (const u of usages) {
|
|
151
|
+
if (u.kinds.length < 2)
|
|
152
|
+
continue;
|
|
153
|
+
// 'plain' 이 유일한 다른 형태이면서 실제 return 문에 undefined 만 있는 경우는 걸러야
|
|
154
|
+
// 하지만, 액션이 render|json 를 하다가 조건에 따라 undefined 반환하면 혼용이다. 유지.
|
|
155
|
+
const rel = relative(process.cwd(), full);
|
|
156
|
+
issues.push({
|
|
157
|
+
file: rel,
|
|
158
|
+
line: u.line,
|
|
159
|
+
action: u.action,
|
|
160
|
+
kinds: u.kinds,
|
|
161
|
+
message: `[doctor] 응답 혼용 감지: ${rel}:${u.line} · 액션 '${u.action}' 이 서로 다른 응답 형태를 섞습니다 (${u.kinds.join(', ')}).\n` +
|
|
162
|
+
`→ 한 액션은 한 응답 형태만 유지하세요 (render | JSON | redirect · errata E-3 §C).\n` +
|
|
163
|
+
`→ 조건 분기가 필요하면 액션을 둘로 나누거나, JSON 반환에는 예외를 던져 상태 코드를 표현하세요.`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { ok: issues.length === 0, checked, issues };
|
|
169
|
+
}
|
|
170
|
+
async function safeListDirs(dir) {
|
|
171
|
+
try {
|
|
172
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
173
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function safeListFiles(dir) {
|
|
180
|
+
try {
|
|
181
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
182
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// stat 은 API 표면 유지용 — 파일 존재 확인이 필요할 때 씀.
|
|
189
|
+
export async function fileExists(file) {
|
|
190
|
+
try {
|
|
191
|
+
await stat(file);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** `gaon doctor` 진입점. */
|
|
199
|
+
export async function runDoctorCommand(opts = {}) {
|
|
200
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
201
|
+
const appsDir = resolve(cwd, 'apps');
|
|
202
|
+
const result = await runDoctor(appsDir);
|
|
203
|
+
if (opts.json) {
|
|
204
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
process.stdout.write(` 검사한 액션: ${result.checked}개\n`);
|
|
208
|
+
if (result.ok) {
|
|
209
|
+
process.stdout.write(` ✓ 응답 혼용 없음\n`);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
process.stdout.write(` ✗ 응답 혼용 ${result.issues.length}건\n`);
|
|
213
|
+
for (const i of result.issues)
|
|
214
|
+
process.stdout.write(i.message + '\n');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return result.ok ? 0 : 1;
|
|
218
|
+
}
|
package/dist/domain.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ScheduleDef } from '@gaonjs/async';
|
|
2
|
+
export interface LoadedDomain {
|
|
3
|
+
readonly jobs: number;
|
|
4
|
+
readonly listeners: number;
|
|
5
|
+
readonly mails: number;
|
|
6
|
+
readonly schedule?: ScheduleDef;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* domain/ 을 스캔·import 해 잡·리스너·스케줄을 등록한다. 반환값은 등록 요약.
|
|
10
|
+
* .ts 상대 import 해석을 위해 tsResolve 훅을 먼저 켠다.
|
|
11
|
+
*/
|
|
12
|
+
export declare function loadDomain(root: string): Promise<LoadedDomain>;
|
package/dist/domain.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gaonjs/cli · 도메인 로더 (§7 M7 — 파일=등록)
|
|
3
|
+
*
|
|
4
|
+
* `gaon work`(그리고 향후 serve)가 부팅 시 도메인의 비동기 자산을 불러
|
|
5
|
+
* 레지스트리를 채운다. 관례대로 **디렉터리에 파일을 놓는 것이 곧 등록**이고,
|
|
6
|
+
* 잡·리스너의 이름/식별자는 파일명에서 채운다(잡은 그래야 `.later()` 가
|
|
7
|
+
* 어느 프로세스에서든 동작한다 — assignName 이 원본 객체를 mutate).
|
|
8
|
+
*
|
|
9
|
+
* domain/jobs/*.ts → 잡(이름 = 파일명)
|
|
10
|
+
* domain/events/*.ts → 이벤트 정의(리스너가 참조 · import 만)
|
|
11
|
+
* domain/listeners/*.ts → 리스너(durable id = 파일명)
|
|
12
|
+
* domain/schedule.ts → 스케줄 정의(default export)
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { join, basename } from 'node:path';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
17
|
+
import { isJobDef, isListener, reidentifyListener } from '@gaonjs/async';
|
|
18
|
+
import { isMailDef } from '@gaonjs/mail';
|
|
19
|
+
import { registerTsResolve } from './tsResolve.js';
|
|
20
|
+
function tsFilesIn(dir) {
|
|
21
|
+
if (!existsSync(dir))
|
|
22
|
+
return [];
|
|
23
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
24
|
+
.filter((e) => e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.test.ts'))
|
|
25
|
+
.map((e) => join(dir, e.name))
|
|
26
|
+
.sort();
|
|
27
|
+
}
|
|
28
|
+
async function importFile(file) {
|
|
29
|
+
return (await import(pathToFileURL(file).href));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* domain/ 을 스캔·import 해 잡·리스너·스케줄을 등록한다. 반환값은 등록 요약.
|
|
33
|
+
* .ts 상대 import 해석을 위해 tsResolve 훅을 먼저 켠다.
|
|
34
|
+
*/
|
|
35
|
+
export async function loadDomain(root) {
|
|
36
|
+
registerTsResolve();
|
|
37
|
+
const domainDir = join(root, 'domain');
|
|
38
|
+
let jobs = 0;
|
|
39
|
+
let listeners = 0;
|
|
40
|
+
let mails = 0;
|
|
41
|
+
// 이벤트 정의를 먼저 import 해 리스너가 참조할 수 있게 한다(부수효과 없음).
|
|
42
|
+
for (const file of tsFilesIn(join(domainDir, 'events')))
|
|
43
|
+
await importFile(file);
|
|
44
|
+
// 메일 — domain/mails/ 의 파일이 곧 등록(§7). 파일명을 이름으로 심는다.
|
|
45
|
+
for (const file of tsFilesIn(join(domainDir, 'mails'))) {
|
|
46
|
+
const mod = await importFile(file);
|
|
47
|
+
const nm = basename(file, '.ts');
|
|
48
|
+
for (const value of Object.values(mod)) {
|
|
49
|
+
if (isMailDef(value)) {
|
|
50
|
+
value.assignName(nm);
|
|
51
|
+
mails++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const file of tsFilesIn(join(domainDir, 'jobs'))) {
|
|
56
|
+
const mod = await importFile(file);
|
|
57
|
+
const base = basename(file, '.ts');
|
|
58
|
+
for (const value of Object.values(mod)) {
|
|
59
|
+
if (isJobDef(value)) {
|
|
60
|
+
value.assignName(base);
|
|
61
|
+
jobs++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const file of tsFilesIn(join(domainDir, 'listeners'))) {
|
|
66
|
+
const mod = await importFile(file);
|
|
67
|
+
const base = basename(file, '.ts');
|
|
68
|
+
for (const value of Object.values(mod)) {
|
|
69
|
+
if (isListener(value)) {
|
|
70
|
+
reidentifyListener(value, base);
|
|
71
|
+
listeners++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let schedule;
|
|
76
|
+
const scheduleFile = join(domainDir, 'schedule.ts');
|
|
77
|
+
if (existsSync(scheduleFile)) {
|
|
78
|
+
const mod = await importFile(scheduleFile);
|
|
79
|
+
const def = mod.default;
|
|
80
|
+
if (def && typeof def === 'object' && Array.isArray(def.entries)) {
|
|
81
|
+
schedule = def;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { jobs, listeners, mails, schedule };
|
|
85
|
+
}
|
package/dist/hub.d.ts
CHANGED
|
@@ -4,9 +4,11 @@ export interface HubCommandOptions {
|
|
|
4
4
|
readonly natsUrl?: string;
|
|
5
5
|
/** 리스 TTL(ms). 생략 시 env GAON_HUB_TTL_MS, 그다음 허브 기본(5000). */
|
|
6
6
|
readonly ttlMs?: number;
|
|
7
|
-
/**
|
|
8
|
-
readonly
|
|
9
|
-
/**
|
|
7
|
+
/** TCP 리슨 포트(errata E-2). 생략 시 env GAON_HUB_PORT, 그다음 4001. */
|
|
8
|
+
readonly port?: number;
|
|
9
|
+
/** 소켓 무활동 임계(ms). 생략 시 env GAON_HUB_PING_TIMEOUT_MS. */
|
|
10
|
+
readonly pingTimeoutMs?: number;
|
|
11
|
+
/** 무활동·회수 스윕 주기(ms). 생략 시 env GAON_HUB_SWEEP_MS. */
|
|
10
12
|
readonly sweepMs?: number;
|
|
11
13
|
/** 시그널 등록·해제(테스트 주입). 기본 process. */
|
|
12
14
|
readonly signals?: {
|
package/dist/hub.js
CHANGED
|
@@ -54,10 +54,14 @@ export async function runHubCommand(opts = {}) {
|
|
|
54
54
|
nats,
|
|
55
55
|
id,
|
|
56
56
|
// 운영 튜닝: 옵션 > 환경변수 > 허브 기본. 컨테이너 배치에서 헬스체크
|
|
57
|
-
// 주기에 맞춰 리스 TTL
|
|
57
|
+
// 주기에 맞춰 리스 TTL·TCP 포트·소켓 임계를 조정한다.
|
|
58
58
|
ttlMs: opts.ttlMs ?? envInt('GAON_HUB_TTL_MS'),
|
|
59
|
-
|
|
59
|
+
port: opts.port ?? envInt('GAON_HUB_PORT'),
|
|
60
|
+
host: process.env.GAON_HUB_HOST,
|
|
61
|
+
advertiseAddr: process.env.GAON_HUB_ADVERTISE,
|
|
62
|
+
pingTimeoutMs: opts.pingTimeoutMs ?? envInt('GAON_HUB_PING_TIMEOUT_MS'),
|
|
60
63
|
sweepMs: opts.sweepMs ?? envInt('GAON_HUB_SWEEP_MS'),
|
|
64
|
+
reclaimGraceMs: envInt('GAON_HUB_RECLAIM_GRACE_MS'),
|
|
61
65
|
onState: (s) => emit({ kind: 'leader', leader: s.leader, id }),
|
|
62
66
|
});
|
|
63
67
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,11 @@ export { runDevCommand, startDev, resolveDevLayout, type DevDeps, type DevLayout
|
|
|
2
2
|
export { runCheck, runCheckCommand, type CheckDeps, type CheckResult, type TypecheckResult, type CheckCommandOptions, } from "./check.js";
|
|
3
3
|
export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, type AuthScaffoldOptions, type ScaffoldFile, type ScaffoldResult, type GenerateAuthOptions, } from "./generate.js";
|
|
4
4
|
export { runHubCommand, type HubCommandOptions } from "./hub.js";
|
|
5
|
+
export { runWorkCommand, type WorkCommandOptions } from "./work.js";
|
|
6
|
+
export { runJobsCommand, type JobsCommandOptions } from "./jobs.js";
|
|
7
|
+
export { runDbSeedCommand, loadSeed, type DbSeedOptions, type DbSeedResult } from "./db.js";
|
|
8
|
+
export { runDoctorCommand, runDoctor, inspectControllerSource, type DoctorResult, type DoctorIssue, type ActionUsage, type ResponseKind, type DoctorCommandOptions, } from "./doctor.js";
|
|
9
|
+
export { loadDomain, type LoadedDomain } from "./domain.js";
|
|
5
10
|
export interface RoadmapReport {
|
|
6
11
|
readonly name: "gaon";
|
|
7
12
|
readonly version: string;
|
package/dist/index.js
CHANGED
|
@@ -14,10 +14,19 @@ import { runDevCommand } from "./dev.js";
|
|
|
14
14
|
import { runCheckCommand } from "./check.js";
|
|
15
15
|
import { runGenerateAuthCommand } from "./generate.js";
|
|
16
16
|
import { runHubCommand } from "./hub.js";
|
|
17
|
+
import { runWorkCommand } from "./work.js";
|
|
18
|
+
import { runJobsCommand } from "./jobs.js";
|
|
19
|
+
import { runDbSeedCommand } from "./db.js";
|
|
20
|
+
import { runDoctorCommand } from "./doctor.js";
|
|
17
21
|
export { runDevCommand, startDev, resolveDevLayout, } from "./dev.js";
|
|
18
22
|
export { runCheck, runCheckCommand, } from "./check.js";
|
|
19
23
|
export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, } from "./generate.js";
|
|
20
24
|
export { runHubCommand } from "./hub.js";
|
|
25
|
+
export { runWorkCommand } from "./work.js";
|
|
26
|
+
export { runJobsCommand } from "./jobs.js";
|
|
27
|
+
export { runDbSeedCommand, loadSeed } from "./db.js";
|
|
28
|
+
export { runDoctorCommand, runDoctor, inspectControllerSource, } from "./doctor.js";
|
|
29
|
+
export { loadDomain } from "./domain.js";
|
|
21
30
|
/** `--json` 출력용 구조화 리포트. */
|
|
22
31
|
export function roadmapReport(version = VERSION) {
|
|
23
32
|
return {
|
|
@@ -64,8 +73,12 @@ function renderHelp(version = VERSION) {
|
|
|
64
73
|
" gaon dev .gaon 타입 브리지를 감시·재생성 (스키마·라우트)",
|
|
65
74
|
" gaon dev --json 재생성 이벤트를 JSON 으로 출력",
|
|
66
75
|
" gaon check .gaon 재생성 후 타입 검사 (CI·AI 정합)",
|
|
76
|
+
" gaon doctor 정적 검사 (응답 혼용·의존 방향 등)",
|
|
67
77
|
" gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트)",
|
|
68
78
|
" gaon hub 실시간 허브 프로세스 (프레즌스 권위·중계 · 리더 선출 HA)",
|
|
79
|
+
" gaon work 워커 프로세스 (잡·리스너·스케줄·아웃박스 · graceful drain)",
|
|
80
|
+
" gaon jobs list --failed DLQ(실패 잡) 목록",
|
|
81
|
+
" gaon jobs retry <id> DLQ 잡 재적재",
|
|
69
82
|
" gaon --json 같은 정보를 JSON 으로 출력",
|
|
70
83
|
" gaon --version 버전 출력",
|
|
71
84
|
" gaon --help 이 도움말",
|
|
@@ -100,6 +113,19 @@ export function runCli(argv, opts = {}) {
|
|
|
100
113
|
});
|
|
101
114
|
return;
|
|
102
115
|
}
|
|
116
|
+
// `gaon doctor` — 정적 검사(응답 혼용 등 · errata E-3 §C). --fix 는 후속.
|
|
117
|
+
if (argv[0] === "doctor") {
|
|
118
|
+
void runDoctorCommand({ json: argv.includes("--json") })
|
|
119
|
+
.then((code) => {
|
|
120
|
+
process.exitCode = code;
|
|
121
|
+
})
|
|
122
|
+
.catch((err) => {
|
|
123
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
124
|
+
process.stderr.write(` ✗ gaon doctor 실패: ${msg}\n`);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
103
129
|
// `gaon hub` — 실시간 허브 프로세스(§7 M6). 운영 프로세스 3종 중 하나.
|
|
104
130
|
// NATS·리더 선출 대기로 프로세스를 살려 두고, SIGINT/SIGTERM 에 그레이스풀 종료.
|
|
105
131
|
if (argv[0] === "hub") {
|
|
@@ -110,6 +136,44 @@ export function runCli(argv, opts = {}) {
|
|
|
110
136
|
});
|
|
111
137
|
return;
|
|
112
138
|
}
|
|
139
|
+
// `gaon work` — 워커 프로세스(§7 M7). 잡·리스너·스케줄·아웃박스 조립.
|
|
140
|
+
// 시그널에 graceful drain 후 종료.
|
|
141
|
+
if (argv[0] === "work") {
|
|
142
|
+
void runWorkCommand({ json: argv.includes("--json") }).catch((err) => {
|
|
143
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
144
|
+
process.stderr.write(` ✗ gaon work 실패: ${msg}\n`);
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// `gaon jobs` — DLQ 조회·재적재(§7 M7). list --failed | retry <id>.
|
|
150
|
+
if (argv[0] === "jobs") {
|
|
151
|
+
void runJobsCommand(argv.slice(1), { json: argv.includes("--json") })
|
|
152
|
+
.then((code) => {
|
|
153
|
+
process.exitCode = code;
|
|
154
|
+
})
|
|
155
|
+
.catch((err) => {
|
|
156
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
157
|
+
process.stderr.write(` ✗ gaon jobs 실패: ${msg}\n`);
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// `gaon db seed` — domain/seed.ts 실행(§7 M8). diff·migrate 는 M9 CLI 완성.
|
|
163
|
+
if (argv[0] === "db" && argv[1] === "seed") {
|
|
164
|
+
const json = argv.includes("--json");
|
|
165
|
+
void runDbSeedCommand({ json })
|
|
166
|
+
.then((res) => {
|
|
167
|
+
process.stdout.write((json ? JSON.stringify(res.json) : res.text) + "\n");
|
|
168
|
+
process.exitCode = res.exitCode;
|
|
169
|
+
})
|
|
170
|
+
.catch((err) => {
|
|
171
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
172
|
+
process.stderr.write(` ✗ gaon db seed 실패: ${msg}\n`);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
113
177
|
// `gaon g auth` — 인증 스캐폴드 제너레이터(§7 M5). 다른 제너레이터는 후속.
|
|
114
178
|
if (argv[0] === "g" || argv[0] === "generate") {
|
|
115
179
|
if (argv[1] === "auth") {
|
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface JobsCommandOptions {
|
|
2
|
+
readonly json?: boolean;
|
|
3
|
+
/** NATS 접속지. 생략 시 GAON_NATS_URL, 그다음 기본(4222). */
|
|
4
|
+
readonly natsUrl?: string;
|
|
5
|
+
}
|
|
6
|
+
/** `gaon jobs ...` 진입점. 종료 코드를 돌려준다(0 성공 · 1 실패). */
|
|
7
|
+
export declare function runJobsCommand(argv: readonly string[], opts?: JobsCommandOptions): Promise<number>;
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gaonjs/cli · `gaon jobs` — DLQ 조회·재적재 (§7 · M7 · line 886~887)
|
|
3
|
+
*
|
|
4
|
+
* 최대 재시도를 소진해 DLQ 로 간 잡을 운영자가 확인하고 재실행한다:
|
|
5
|
+
* gaon jobs list --failed 실패 잡 목록(최신순)
|
|
6
|
+
* gaon jobs retry <id> DLQ 잡을 잡 큐로 재적재
|
|
7
|
+
* 모든 명령과 같이 `--json` 을 제공한다.
|
|
8
|
+
*/
|
|
9
|
+
import { connectNats, listDlq, retryDlq } from '@gaonjs/async';
|
|
10
|
+
function fmtTime(ms) {
|
|
11
|
+
return new Date(ms).toISOString();
|
|
12
|
+
}
|
|
13
|
+
function renderList(entries) {
|
|
14
|
+
if (entries.length === 0)
|
|
15
|
+
return ' DLQ 가 비어 있습니다 — 실패한 잡이 없습니다.';
|
|
16
|
+
const lines = [` 실패 잡 ${entries.length}건 (최신순):`, ''];
|
|
17
|
+
for (const e of entries) {
|
|
18
|
+
lines.push(` ${e.id} ${e.name} (시도 ${e.attempts}회 · ${fmtTime(e.failedAt)})`);
|
|
19
|
+
lines.push(` ↳ ${e.error}`);
|
|
20
|
+
}
|
|
21
|
+
lines.push('');
|
|
22
|
+
lines.push(' 재적재: gaon jobs retry <id>');
|
|
23
|
+
return lines.join('\n');
|
|
24
|
+
}
|
|
25
|
+
/** `gaon jobs ...` 진입점. 종료 코드를 돌려준다(0 성공 · 1 실패). */
|
|
26
|
+
export async function runJobsCommand(argv, opts = {}) {
|
|
27
|
+
const json = opts.json ?? argv.includes('--json');
|
|
28
|
+
const sub = argv[0];
|
|
29
|
+
if (sub === 'list') {
|
|
30
|
+
const failedOnly = argv.includes('--failed');
|
|
31
|
+
if (!failedOnly) {
|
|
32
|
+
process.stderr.write(` ✗ gaon jobs list 는 현재 --failed(DLQ) 만 지원합니다.\n` +
|
|
33
|
+
` → gaon jobs list --failed\n`);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
const nats = await connectNats({ servers: opts.natsUrl, name: 'jobs-cli' });
|
|
37
|
+
try {
|
|
38
|
+
const entries = await listDlq(nats);
|
|
39
|
+
if (json)
|
|
40
|
+
process.stdout.write(JSON.stringify({ failed: entries }, null, 2) + '\n');
|
|
41
|
+
else
|
|
42
|
+
process.stdout.write(renderList(entries) + '\n');
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await nats.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (sub === 'retry') {
|
|
50
|
+
const id = argv[1];
|
|
51
|
+
if (!id || id.startsWith('--')) {
|
|
52
|
+
process.stderr.write(` ✗ 재적재할 잡 id 가 필요합니다.\n → gaon jobs retry <id>\n`);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const nats = await connectNats({ servers: opts.natsUrl, name: 'jobs-cli' });
|
|
56
|
+
try {
|
|
57
|
+
const entry = await retryDlq(nats, id);
|
|
58
|
+
if (json)
|
|
59
|
+
process.stdout.write(JSON.stringify({ retried: entry }, null, 2) + '\n');
|
|
60
|
+
else
|
|
61
|
+
process.stdout.write(` ↺ 재적재 완료 — ${entry.name} (id ${entry.id})\n`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
66
|
+
process.stderr.write(` ✗ ${msg}\n`);
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await nats.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
process.stderr.write(` ✗ 알 수 없는 jobs 명령: ${sub ?? '(없음)'}\n` +
|
|
74
|
+
` → gaon jobs list --failed | gaon jobs retry <id>\n`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
package/dist/work.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface WorkCommandOptions {
|
|
2
|
+
readonly json?: boolean;
|
|
3
|
+
/** NATS 접속지. 생략 시 GAON_NATS_URL, 그다음 기본(4222). */
|
|
4
|
+
readonly natsUrl?: string;
|
|
5
|
+
/** 도메인 루트(domain/ 의 부모). 기본 cwd. */
|
|
6
|
+
readonly root?: string;
|
|
7
|
+
/** 아웃박스 릴레이용 DB URL. 생략 시 GAON_DATABASE_URL, 없으면 릴레이 미기동. */
|
|
8
|
+
readonly databaseUrl?: string;
|
|
9
|
+
/** 큐 기본 동시성. */
|
|
10
|
+
readonly concurrency?: number;
|
|
11
|
+
/** ack 대기(ms). 생략 시 GAON_WORKER_ACK_WAIT_MS. */
|
|
12
|
+
readonly ackWaitMs?: number;
|
|
13
|
+
/** graceful drain 상한(ms). 생략 시 GAON_WORKER_DRAIN_MS. */
|
|
14
|
+
readonly drainTimeoutMs?: number;
|
|
15
|
+
/** 시그널 등록·해제(테스트 주입). 기본 process. */
|
|
16
|
+
readonly signals?: {
|
|
17
|
+
on(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
|
|
18
|
+
off(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 워커를 띄우고 시그널까지 살려 둔다. 반환 프라미스는 graceful 종료 시 resolve.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runWorkCommand(opts?: WorkCommandOptions): Promise<void>;
|
package/dist/work.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gaonjs/cli · `gaon work` — 워커 프로세스 (§7 · M7)
|
|
3
|
+
*
|
|
4
|
+
* 운영 프로세스 3종(serve · work · hub) 중 하나. 도메인의 잡·리스너·스케줄·
|
|
5
|
+
* 아웃박스를 한 프로세스에 조립해 돌린다(@gaonjs/async runWork). 개발
|
|
6
|
+
* 모드는 `gaon dev` 가 워커를 내장 실행하므로 이 명령은 운영 배치용이다.
|
|
7
|
+
*
|
|
8
|
+
* 종료 시그널(SIGTERM·SIGINT)에 graceful drain — 스케줄러 리더 반납 →
|
|
9
|
+
* 릴레이·리스너·워커 순으로 내리고, 진행 중 잡은 상한까지 기다린다. 모든
|
|
10
|
+
* 명령과 같이 `--json` 을 제공한다.
|
|
11
|
+
*/
|
|
12
|
+
import { hostname } from 'node:os';
|
|
13
|
+
import { connectNats, runWork } from '@gaonjs/async';
|
|
14
|
+
import { createDb } from '@gaonjs/data';
|
|
15
|
+
import { loadDomain } from './domain.js';
|
|
16
|
+
/** DB URL 에서 어댑터를 추정한다(postgres·mysql). */
|
|
17
|
+
function dbConfigFromUrl(url) {
|
|
18
|
+
if (url.startsWith('mysql://') || url.startsWith('mariadb://')) {
|
|
19
|
+
return { adapter: 'mysql', url, poolMax: 4 };
|
|
20
|
+
}
|
|
21
|
+
return { adapter: 'postgres', url, poolMax: 4 };
|
|
22
|
+
}
|
|
23
|
+
/** 환경변수를 정수로 파싱(미설정·비정상 시 undefined). */
|
|
24
|
+
function envInt(name) {
|
|
25
|
+
const raw = process.env[name];
|
|
26
|
+
if (raw == null || raw === '')
|
|
27
|
+
return undefined;
|
|
28
|
+
const n = Number(raw);
|
|
29
|
+
return Number.isFinite(n) ? n : undefined;
|
|
30
|
+
}
|
|
31
|
+
function emitHuman(e) {
|
|
32
|
+
switch (e.kind) {
|
|
33
|
+
case 'ready':
|
|
34
|
+
return ` gaon work · 준비 — 잡 ${e.jobs} · 리스너 ${e.listeners} · 스케줄 ${e.scheduled}`;
|
|
35
|
+
case 'scheduler':
|
|
36
|
+
if (e.event.kind === 'leader')
|
|
37
|
+
return e.event.leader ? ' ▶ 스케줄러 리더 — 틱 발행 시작' : ' · 스케줄러 대기(standby)';
|
|
38
|
+
if (e.event.kind === 'fired')
|
|
39
|
+
return ` ⏰ 스케줄 발행 — ${e.event.label}`;
|
|
40
|
+
return undefined;
|
|
41
|
+
case 'worker':
|
|
42
|
+
if (e.event.kind === 'dead')
|
|
43
|
+
return ` ✗ 잡 DLQ — ${e.event.job} (${e.event.error})`;
|
|
44
|
+
return undefined;
|
|
45
|
+
case 'relay':
|
|
46
|
+
return ` ↪ 아웃박스 릴레이 — ${e.count}건 발행`;
|
|
47
|
+
default:
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 워커를 띄우고 시그널까지 살려 둔다. 반환 프라미스는 graceful 종료 시 resolve.
|
|
53
|
+
*/
|
|
54
|
+
export async function runWorkCommand(opts = {}) {
|
|
55
|
+
const json = opts.json ?? false;
|
|
56
|
+
const signals = opts.signals ?? process;
|
|
57
|
+
const root = opts.root ?? process.cwd();
|
|
58
|
+
const id = `${hostname()}#${process.pid}`;
|
|
59
|
+
const emit = (e) => {
|
|
60
|
+
if (json) {
|
|
61
|
+
process.stdout.write(JSON.stringify(e) + '\n');
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const line = emitHuman(e);
|
|
65
|
+
if (line)
|
|
66
|
+
process.stdout.write(line + '\n');
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const nats = await connectNats({ servers: opts.natsUrl, name: `work@${id}` });
|
|
70
|
+
let db;
|
|
71
|
+
const dbUrl = opts.databaseUrl ?? process.env.GAON_DATABASE_URL;
|
|
72
|
+
if (dbUrl)
|
|
73
|
+
db = createDb(dbConfigFromUrl(dbUrl));
|
|
74
|
+
const domain = await loadDomain(root);
|
|
75
|
+
// 로드된 도메인 자산 요약(파일=등록 관측용). 잡·리스너·메일 수를 노출한다.
|
|
76
|
+
if (json) {
|
|
77
|
+
process.stdout.write(JSON.stringify({
|
|
78
|
+
kind: 'domain',
|
|
79
|
+
jobs: domain.jobs,
|
|
80
|
+
listeners: domain.listeners,
|
|
81
|
+
mails: domain.mails,
|
|
82
|
+
}) + '\n');
|
|
83
|
+
}
|
|
84
|
+
let work;
|
|
85
|
+
try {
|
|
86
|
+
work = await runWork({
|
|
87
|
+
nats,
|
|
88
|
+
id,
|
|
89
|
+
db,
|
|
90
|
+
schedule: domain.schedule,
|
|
91
|
+
concurrency: opts.concurrency,
|
|
92
|
+
ackWaitMs: opts.ackWaitMs ?? envInt('GAON_WORKER_ACK_WAIT_MS'),
|
|
93
|
+
drainTimeoutMs: opts.drainTimeoutMs ?? envInt('GAON_WORKER_DRAIN_MS'),
|
|
94
|
+
onEvent: emit,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (db)
|
|
99
|
+
await db.destroy().catch(() => { });
|
|
100
|
+
await nats.close();
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
await new Promise((resolve) => {
|
|
104
|
+
const stop = () => {
|
|
105
|
+
signals.off('SIGINT', stop);
|
|
106
|
+
signals.off('SIGTERM', stop);
|
|
107
|
+
void (async () => {
|
|
108
|
+
await work.stop();
|
|
109
|
+
if (db)
|
|
110
|
+
await db.destroy().catch(() => { });
|
|
111
|
+
await nats.close();
|
|
112
|
+
if (json)
|
|
113
|
+
process.stdout.write(JSON.stringify({ kind: 'stopped' }) + '\n');
|
|
114
|
+
else
|
|
115
|
+
process.stdout.write(' gaon work · 종료\n');
|
|
116
|
+
resolve();
|
|
117
|
+
})();
|
|
118
|
+
};
|
|
119
|
+
signals.on('SIGINT', stop);
|
|
120
|
+
signals.on('SIGTERM', stop);
|
|
121
|
+
});
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"
|
|
28
|
-
"@gaonjs/
|
|
29
|
-
"@gaonjs/
|
|
30
|
-
"@gaonjs/data": "0.2.
|
|
27
|
+
"typescript": "*",
|
|
28
|
+
"@gaonjs/async": "0.2.2",
|
|
29
|
+
"@gaonjs/core": "0.1.4",
|
|
30
|
+
"@gaonjs/data": "0.2.3",
|
|
31
|
+
"@gaonjs/mail": "0.1.0",
|
|
32
|
+
"@gaonjs/web": "0.3.0"
|
|
31
33
|
},
|
|
32
34
|
"scripts": {
|
|
33
35
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|