@gaonjs/cli 0.31.0 → 0.32.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/db.d.ts +5 -1
- package/dist/commands/db.js +92 -36
- package/dist/db/resolve.d.ts +4 -6
- package/dist/db/resolve.js +26 -13
- package/dist/doctor/connections.d.ts +23 -1
- package/dist/doctor/connections.js +85 -14
- package/dist/doctor/schema-relations.d.ts +7 -0
- package/dist/doctor/schema-relations.js +114 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +1 -1
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +8 -2
- package/dist/index.js +4 -4
- package/dist/templates/project/.env.example.tpl +8 -0
- package/dist/templates/project/AGENTS.md.tpl +12 -6
- package/dist/templates/project/agents/async.md.tpl +17 -0
- package/dist/templates/project/agents/data.md.tpl +56 -12
- package/dist/templates/project/agents/security.md.tpl +7 -0
- package/dist/templates/project/agents/storage.md.tpl +110 -0
- package/dist/templates/project/agents/testing.md.tpl +19 -5
- package/dist/templates/project/agents/web.md.tpl +29 -0
- package/dist/templates/project/docker-compose.yaml.tpl +20 -0
- package/dist/templates/project/gaon.config.ts.tpl +21 -0
- package/dist/templates/project/test/setup.ts.tpl +10 -7
- package/dist/templates/project/vitest.config.ts.tpl +5 -0
- package/package.json +4 -4
package/dist/commands/db.d.ts
CHANGED
|
@@ -4,7 +4,11 @@ export interface DbCommandOptions {
|
|
|
4
4
|
readonly cwd?: string;
|
|
5
5
|
/** 자동화 출력. 기본 false(사람 텍스트). */
|
|
6
6
|
readonly json?: boolean;
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* 커넥션 키(§4.5). 결정 139: diff/migrate/status/seed 는 생략(또는 'all') 시
|
|
9
|
+
* 등록된 **전 커넥션**을 순회 적용한다 · `--db <키>` 는 단일 좁힘. reset(파괴적)은
|
|
10
|
+
* 항상 단일이며 생략 시 'main'.
|
|
11
|
+
*/
|
|
8
12
|
readonly db?: string;
|
|
9
13
|
/** --config <path> 오버라이드. 없으면 cwd/gaon.config.ts 관례. */
|
|
10
14
|
readonly config?: string;
|
package/dist/commands/db.js
CHANGED
|
@@ -11,7 +11,12 @@ import { runDbDiff } from '../db/diff.js';
|
|
|
11
11
|
import { runDbMigrate } from '../db/migrate.js';
|
|
12
12
|
import { runDbReset } from '../db/reset.js';
|
|
13
13
|
import { runDbStatus } from '../db/status.js';
|
|
14
|
+
import { listConnectionKeys } from '../db/resolve.js';
|
|
14
15
|
import { runDbSeedCommand } from '../db.js';
|
|
16
|
+
/** 전 커넥션 순회 대상 서브커맨드(결정 139 · reset 은 파괴적이라 제외 · 단일 유지). */
|
|
17
|
+
const MULTI_CONN_SUBS = new Set(['diff', 'migrate', 'status', 'seed']);
|
|
18
|
+
/** 알려진 서브커맨드. dispatcher 방어(모르는 값이 오면 seed 로 새지 않게). */
|
|
19
|
+
const KNOWN_SUBS = new Set(['diff', 'migrate', 'reset', 'seed', 'status']);
|
|
15
20
|
/**
|
|
16
21
|
* `gaon db <sub>` 진입점. 사람/JSON 출력을 자체 처리하고 exitCode 를
|
|
17
22
|
* 돌려준다. seed 는 기존 M8 runDbSeedCommand 로 위임(신규 서명에 정합).
|
|
@@ -19,7 +24,6 @@ import { runDbSeedCommand } from '../db.js';
|
|
|
19
24
|
export async function runDbCommand(subcommand, opts = {}) {
|
|
20
25
|
const cwd = opts.cwd ?? process.cwd();
|
|
21
26
|
const json = opts.json ?? false;
|
|
22
|
-
const dbKey = opts.db ?? 'main';
|
|
23
27
|
const configPath = opts.config;
|
|
24
28
|
const dryRun = opts.dryRun ?? false;
|
|
25
29
|
const emit = (text, jsonValue) => {
|
|
@@ -28,45 +32,55 @@ export async function runDbCommand(subcommand, opts = {}) {
|
|
|
28
32
|
else
|
|
29
33
|
process.stdout.write(text + '\n');
|
|
30
34
|
};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (subcommand === '
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
if (subcommand === 'reset') {
|
|
48
|
-
const r = await runDbReset({
|
|
49
|
-
cwd,
|
|
50
|
-
dbKey,
|
|
51
|
-
json,
|
|
52
|
-
yes: opts.yes ?? false,
|
|
53
|
-
dryRun,
|
|
54
|
-
configPath,
|
|
55
|
-
});
|
|
56
|
-
emit(r.text, r.json);
|
|
57
|
-
return r.exitCode;
|
|
58
|
-
}
|
|
59
|
-
if (subcommand === 'seed') {
|
|
60
|
-
// 결정 101: seed 도 diff/migrate 와 같은 설정 해석 경로(config → env)를 쓴다.
|
|
61
|
-
const r = await runDbSeedCommand({ root: cwd, json, configPath, dbKey });
|
|
62
|
-
emit(r.text, r.json);
|
|
63
|
-
return r.exitCode;
|
|
64
|
-
}
|
|
65
|
-
// 방어 — dispatcher 라 도달할 수 없지만 컴파일러 만족용.
|
|
35
|
+
// 한 커넥션에 대해 서브커맨드를 실행한다(순회 루프의 단위).
|
|
36
|
+
const runOne = async (subKey) => {
|
|
37
|
+
if (subcommand === 'diff')
|
|
38
|
+
return runDbDiff({ cwd, dbKey: subKey, json, configPath });
|
|
39
|
+
if (subcommand === 'migrate')
|
|
40
|
+
return runDbMigrate({ cwd, dbKey: subKey, json, dryRun, down: opts.down ?? false, configPath });
|
|
41
|
+
if (subcommand === 'status')
|
|
42
|
+
return runDbStatus({ cwd, dbKey: subKey, json, configPath });
|
|
43
|
+
if (subcommand === 'reset')
|
|
44
|
+
return runDbReset({ cwd, dbKey: subKey, json, yes: opts.yes ?? false, dryRun, configPath });
|
|
45
|
+
// seed (결정 101: diff/migrate 와 같은 설정 해석 경로 config → env).
|
|
46
|
+
return runDbSeedCommand({ root: cwd, json, configPath, dbKey: subKey });
|
|
47
|
+
};
|
|
48
|
+
// 방어 — dispatcher 라 도달할 수 없지만, 모르는 서브커맨드가 seed 로 새지 않게.
|
|
49
|
+
if (!KNOWN_SUBS.has(subcommand)) {
|
|
66
50
|
process.stderr.write(` ✗ 알 수 없는 db 서브커맨드: ${String(subcommand)}\n` +
|
|
67
51
|
` → 지원: diff | migrate | reset | seed | status\n`);
|
|
68
52
|
return 1;
|
|
69
53
|
}
|
|
54
|
+
try {
|
|
55
|
+
// 결정 139: diff/migrate/status/seed 는 --db 생략(또는 'all') 시 등록된 전
|
|
56
|
+
// 커넥션을 순회 적용한다(One Way — 커넥션 하나를 잊어 빈 채 배포하는 사고 방지).
|
|
57
|
+
// `--db <키>` 는 단일 좁힘. reset(파괴적)은 항상 단일(main 폴백).
|
|
58
|
+
const wantsAll = subcommand !== 'reset' && (opts.db === undefined || opts.db === 'all');
|
|
59
|
+
if (wantsAll && MULTI_CONN_SUBS.has(subcommand)) {
|
|
60
|
+
const keys = await listConnectionKeys(cwd, configPath);
|
|
61
|
+
// 단일 커넥션이면 종전 출력과 100% 동일(하위 호환).
|
|
62
|
+
if (keys.length === 1) {
|
|
63
|
+
const r = await runOne(keys[0]);
|
|
64
|
+
emit(r.text, r.json);
|
|
65
|
+
return r.exitCode;
|
|
66
|
+
}
|
|
67
|
+
return runAcrossConnections(subcommand, keys, runOne, emit, json);
|
|
68
|
+
}
|
|
69
|
+
// 단일 커넥션 경로(--db <키> · reset · env-only 폴백) — 종전 동작 그대로.
|
|
70
|
+
if (subcommand === 'reset' && opts.db === 'all') {
|
|
71
|
+
// reset 은 전 커넥션 순회를 하지 않는다(파괴적). 명시적으로 거부한다.
|
|
72
|
+
const msg = `reset 은 전 커넥션 순회를 지원하지 않습니다(파괴적).\n` +
|
|
73
|
+
`→ 커넥션을 하나씩 지정하세요: 'gaon db reset --db main --yes'.`;
|
|
74
|
+
if (json)
|
|
75
|
+
process.stdout.write(JSON.stringify({ command: 'reset', ok: false, error: msg }) + '\n');
|
|
76
|
+
else
|
|
77
|
+
process.stderr.write(` ✗ ${msg}\n`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
const r = await runOne(opts.db ?? 'main');
|
|
81
|
+
emit(r.text, r.json);
|
|
82
|
+
return r.exitCode;
|
|
83
|
+
}
|
|
70
84
|
catch (err) {
|
|
71
85
|
const msg = err instanceof Error ? err.message : String(err);
|
|
72
86
|
if (json) {
|
|
@@ -78,3 +92,45 @@ export async function runDbCommand(subcommand, opts = {}) {
|
|
|
78
92
|
return 1;
|
|
79
93
|
}
|
|
80
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* 전 커넥션 순회 실행(결정 139). 각 커넥션을 등록 순서대로 돌리고, 한 커넥션이
|
|
97
|
+
* 실패해도 나머지를 계속한다(부분 적용 방지가 아니라 부분 진행 후 전체 보고 — 어느
|
|
98
|
+
* 커넥션이 성공/실패했는지가 사용자에게 필요). exitCode = 하나라도 실패면 1.
|
|
99
|
+
*/
|
|
100
|
+
async function runAcrossConnections(subcommand, keys, runOne, emit, json) {
|
|
101
|
+
const results = [];
|
|
102
|
+
for (const key of keys) {
|
|
103
|
+
try {
|
|
104
|
+
const r = await runOne(key);
|
|
105
|
+
results.push({ db: key, ok: r.exitCode === 0, exitCode: r.exitCode, text: r.text, json: r.json });
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
109
|
+
results.push({
|
|
110
|
+
db: key,
|
|
111
|
+
ok: false,
|
|
112
|
+
exitCode: 1,
|
|
113
|
+
text: ` ✗ [${key}] gaon db ${subcommand} 실패: ${msg}`,
|
|
114
|
+
json: { command: subcommand, db: key, ok: false, error: msg },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const failed = results.filter((r) => !r.ok);
|
|
119
|
+
const exitCode = failed.length > 0 ? 1 : 0;
|
|
120
|
+
if (json) {
|
|
121
|
+
emit('', {
|
|
122
|
+
command: subcommand,
|
|
123
|
+
connections: keys,
|
|
124
|
+
ok: failed.length === 0,
|
|
125
|
+
results: results.map((r) => ({ db: r.db, ok: r.ok, ...r.json })),
|
|
126
|
+
});
|
|
127
|
+
return exitCode;
|
|
128
|
+
}
|
|
129
|
+
const header = ` gaon db ${subcommand} · 커넥션 ${keys.length}개 순회: ${keys.join(', ')}`;
|
|
130
|
+
const sections = results.map((r) => ` ── [${r.db}] ${'─'.repeat(Math.max(0, 40 - r.db.length))}\n${r.text}`);
|
|
131
|
+
const footer = failed.length === 0
|
|
132
|
+
? ` ✓ 전 커넥션 완료(${keys.length}개).`
|
|
133
|
+
: ` ✗ ${failed.length}/${keys.length} 커넥션 실패: ${failed.map((r) => r.db).join(', ')}`;
|
|
134
|
+
emit([header, ...sections, footer].join('\n'), null);
|
|
135
|
+
return exitCode;
|
|
136
|
+
}
|
package/dist/db/resolve.d.ts
CHANGED
|
@@ -28,11 +28,9 @@ export interface ResolvedDbTarget {
|
|
|
28
28
|
close(): Promise<void>;
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* 커넥션
|
|
34
|
-
* 1) config.db[dbKey] 가 있으면 그 어댑터·URL 사용
|
|
35
|
-
* 2) dbKey='main' 이고 GAON_DATABASE_URL 이 있으면 그 URL 로 main 을 세움
|
|
36
|
-
* 3) 그 외 → 수리 안내 에러
|
|
31
|
+
* 프로젝트의 커넥션 키 목록을 돌려준다(결정 139 · `gaon db <sub>` 전 커넥션 순회용).
|
|
32
|
+
* config.db 에 선언된 키 순서 그대로. 선언이 없으면(env-only 프로젝트) `['main']`
|
|
33
|
+
* 폴백(GAON_DATABASE_URL 관례) — 단일 커넥션 프로젝트의 종전 동작과 정합.
|
|
37
34
|
*/
|
|
35
|
+
export declare function listConnectionKeys(cwd: string, configPath?: string): Promise<string[]>;
|
|
38
36
|
export declare function resolveDbTarget(opts: ResolveDbOptions): Promise<ResolvedDbTarget>;
|
package/dist/db/resolve.js
CHANGED
|
@@ -65,15 +65,15 @@ function isTableDef(v) {
|
|
|
65
65
|
* 2) dbKey='main' 이고 GAON_DATABASE_URL 이 있으면 그 URL 로 main 을 세움
|
|
66
66
|
* 3) 그 외 → 수리 안내 에러
|
|
67
67
|
*/
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* gaon.config 를 로드해 config 값과 실 경로를 돌려준다. --config <path> 가 있으면
|
|
70
|
+
* 그 파일의 default export 를, 없으면 cwd 관례(gaon.config.ts)를 쓴다.
|
|
71
|
+
* resolveDbTarget·listConnectionKeys 가 공유한다(결정 139).
|
|
72
|
+
*/
|
|
73
|
+
async function loadDbConfig(cwd, configPathOpt) {
|
|
70
74
|
registerTsResolve();
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let configPath;
|
|
74
|
-
let config;
|
|
75
|
-
if (opts.configPath) {
|
|
76
|
-
configPath = resolvePath(opts.configPath);
|
|
75
|
+
if (configPathOpt) {
|
|
76
|
+
const configPath = resolvePath(configPathOpt);
|
|
77
77
|
if (!existsSync(configPath)) {
|
|
78
78
|
throw new Error(`[gaon db] --config 로 지정한 파일이 없습니다: ${configPath}\n` +
|
|
79
79
|
`→ 경로를 확인하거나 --config 없이 실행해 cwd/gaon.config.ts 를 쓰세요.`);
|
|
@@ -83,12 +83,25 @@ export async function resolveDbTarget(opts) {
|
|
|
83
83
|
throw new Error(`[gaon db] --config 파일의 default export 가 객체가 아닙니다: ${configPath}\n` +
|
|
84
84
|
`→ export default defineConfig({ db: { main: { adapter: 'postgres', url: '...' } } }) 형태로 두세요.`);
|
|
85
85
|
}
|
|
86
|
-
config
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
configPath = findConfigPath(cwd);
|
|
90
|
-
config = await loadGaonConfig(cwd);
|
|
86
|
+
return { config: mod.default, configPath };
|
|
91
87
|
}
|
|
88
|
+
return { config: await loadGaonConfig(cwd), configPath: findConfigPath(cwd) };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 프로젝트의 커넥션 키 목록을 돌려준다(결정 139 · `gaon db <sub>` 전 커넥션 순회용).
|
|
92
|
+
* config.db 에 선언된 키 순서 그대로. 선언이 없으면(env-only 프로젝트) `['main']`
|
|
93
|
+
* 폴백(GAON_DATABASE_URL 관례) — 단일 커넥션 프로젝트의 종전 동작과 정합.
|
|
94
|
+
*/
|
|
95
|
+
export async function listConnectionKeys(cwd, configPath) {
|
|
96
|
+
const { config } = await loadDbConfig(resolvePath(cwd), configPath);
|
|
97
|
+
const keys = Object.keys(config.db ?? {});
|
|
98
|
+
return keys.length ? keys : ['main'];
|
|
99
|
+
}
|
|
100
|
+
export async function resolveDbTarget(opts) {
|
|
101
|
+
const cwd = resolvePath(opts.cwd);
|
|
102
|
+
// 사용자가 --config <path> 를 준 경우 그 경로에서 default export 를 로드한다.
|
|
103
|
+
// 없으면 cwd 관례.
|
|
104
|
+
const { config, configPath } = await loadDbConfig(cwd, opts.configPath);
|
|
92
105
|
const dbKey = opts.dbKey;
|
|
93
106
|
const cfg = config.db?.[dbKey];
|
|
94
107
|
let connCfg;
|
|
@@ -5,7 +5,29 @@ interface KeyUse {
|
|
|
5
5
|
readonly line: number;
|
|
6
6
|
readonly kind: 'table' | 'getConnection';
|
|
7
7
|
}
|
|
8
|
-
/** gaon.config.ts
|
|
8
|
+
/** gaon.config.ts 정적 분석 결과(결정 135). keys = 뽑아낸 커넥션 키, dbDeclared =
|
|
9
|
+
* db 프로퍼티 존재 여부, unanalyzable = db 초기화식에 정적으로 못 읽는 부분(식별자
|
|
10
|
+
* 참조·함수 호출 등)이 섞여 있었는지. unanalyzable 이면 커넥션 검사가 커버리지를
|
|
11
|
+
* 보장할 수 없어 doctor 가 안내를 낸다(W2). */
|
|
12
|
+
export interface ConfigDbAnalysis {
|
|
13
|
+
readonly keys: string[];
|
|
14
|
+
readonly dbDeclared: boolean;
|
|
15
|
+
readonly unanalyzable: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* gaon.config.ts 를 정적 AST 로 파싱해 db 커넥션 키를 분석한다(결정 135 · W2).
|
|
19
|
+
*
|
|
20
|
+
* 객체 리터럴뿐 아니라 스캐폴드·실사용에서 흔한 형태를 모두 훑는다:
|
|
21
|
+
* - `db: { main: {...} }` → ['main']
|
|
22
|
+
* - `db: process.env.X ? { main:{...} } : undefined` → 삼항 양 분기(결정 135)
|
|
23
|
+
* - `db: cfg ?? { main:{...} }` · `a && { ... }` → ?? · && · || 양변
|
|
24
|
+
* - `db: ({ main:{...} })` → 괄호 벗김
|
|
25
|
+
* - `db: { main, legacy }`(shorthand) → ['main','legacy']
|
|
26
|
+
* 못 읽는 형태(식별자 참조·함수 호출·스프레드)는 unanalyzable=true 로 표시한다 —
|
|
27
|
+
* "키 0" 로 조용히 통과시키던(main 폴백) 스캐폴드 삼항 사각(W2 근본)을 없앤다.
|
|
28
|
+
*/
|
|
29
|
+
export declare function analyzeConfigDb(source: string): ConfigDbAnalysis;
|
|
30
|
+
/** 하위 호환 — db 커넥션 키만 돌려준다(analyzeConfigDb 위임). */
|
|
9
31
|
export declare function extractConfigDbKeys(source: string): string[];
|
|
10
32
|
/** 소스에서 table(name, defs, { db: 'X' }) 와 getConnection('X') 호출을 추출한다. */
|
|
11
33
|
export declare function extractKeyUses(cwd: string, file: string, source: string): KeyUse[];
|
|
@@ -13,26 +13,77 @@ import { existsSync } from 'node:fs';
|
|
|
13
13
|
import { readdir, readFile } from 'node:fs/promises';
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
15
|
import ts from 'typescript';
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* gaon.config.ts 를 정적 AST 로 파싱해 db 커넥션 키를 분석한다(결정 135 · W2).
|
|
18
|
+
*
|
|
19
|
+
* 객체 리터럴뿐 아니라 스캐폴드·실사용에서 흔한 형태를 모두 훑는다:
|
|
20
|
+
* - `db: { main: {...} }` → ['main']
|
|
21
|
+
* - `db: process.env.X ? { main:{...} } : undefined` → 삼항 양 분기(결정 135)
|
|
22
|
+
* - `db: cfg ?? { main:{...} }` · `a && { ... }` → ?? · && · || 양변
|
|
23
|
+
* - `db: ({ main:{...} })` → 괄호 벗김
|
|
24
|
+
* - `db: { main, legacy }`(shorthand) → ['main','legacy']
|
|
25
|
+
* 못 읽는 형태(식별자 참조·함수 호출·스프레드)는 unanalyzable=true 로 표시한다 —
|
|
26
|
+
* "키 0" 로 조용히 통과시키던(main 폴백) 스캐폴드 삼항 사각(W2 근본)을 없앤다.
|
|
27
|
+
*/
|
|
28
|
+
export function analyzeConfigDb(source) {
|
|
18
29
|
const sf = ts.createSourceFile('gaon.config.ts', source, ts.ScriptTarget.ES2022, true);
|
|
19
30
|
const keys = [];
|
|
31
|
+
let dbDeclared = false;
|
|
32
|
+
let unanalyzable = false;
|
|
33
|
+
const collect = (node) => {
|
|
34
|
+
if (ts.isParenthesizedExpression(node))
|
|
35
|
+
return collect(node.expression);
|
|
36
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
37
|
+
for (const dp of node.properties) {
|
|
38
|
+
if (ts.isPropertyAssignment(dp)) {
|
|
39
|
+
const n = propNameText(dp.name);
|
|
40
|
+
if (n)
|
|
41
|
+
keys.push(n);
|
|
42
|
+
else
|
|
43
|
+
unanalyzable = true; // 계산된 키 등
|
|
44
|
+
}
|
|
45
|
+
else if (ts.isShorthandPropertyAssignment(dp)) {
|
|
46
|
+
keys.push(dp.name.text);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
unanalyzable = true; // 스프레드·메서드 등
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (ts.isConditionalExpression(node)) {
|
|
55
|
+
collect(node.whenTrue);
|
|
56
|
+
collect(node.whenFalse);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (ts.isBinaryExpression(node)) {
|
|
60
|
+
const op = node.operatorToken.kind;
|
|
61
|
+
if (op === ts.SyntaxKind.QuestionQuestionToken ||
|
|
62
|
+
op === ts.SyntaxKind.BarBarToken ||
|
|
63
|
+
op === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
64
|
+
collect(node.left);
|
|
65
|
+
collect(node.right);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
unanalyzable = true;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// `undefined` · `null` 분기 = 커넥션 없음(분석 가능 · 키 기여 0).
|
|
72
|
+
if (node.kind === ts.SyntaxKind.NullKeyword)
|
|
73
|
+
return;
|
|
74
|
+
if (ts.isIdentifier(node) && node.text === 'undefined')
|
|
75
|
+
return;
|
|
76
|
+
// 그 외(식별자 참조·함수 호출 등) = 정적으로 못 읽음.
|
|
77
|
+
unanalyzable = true;
|
|
78
|
+
};
|
|
20
79
|
const visit = (node) => {
|
|
21
|
-
// defineConfig({ db: { main: {...}, legacy: {...} } })
|
|
22
80
|
if (ts.isCallExpression(node) && isDefineConfig(node.expression)) {
|
|
23
81
|
const arg = node.arguments[0];
|
|
24
82
|
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
25
83
|
for (const p of arg.properties) {
|
|
26
84
|
if (ts.isPropertyAssignment(p) && propNameText(p.name) === 'db') {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (ts.isPropertyAssignment(dp)) {
|
|
30
|
-
const n = propNameText(dp.name);
|
|
31
|
-
if (n)
|
|
32
|
-
keys.push(n);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
85
|
+
dbDeclared = true;
|
|
86
|
+
collect(p.initializer);
|
|
36
87
|
}
|
|
37
88
|
}
|
|
38
89
|
}
|
|
@@ -40,7 +91,11 @@ export function extractConfigDbKeys(source) {
|
|
|
40
91
|
ts.forEachChild(node, visit);
|
|
41
92
|
};
|
|
42
93
|
visit(sf);
|
|
43
|
-
return keys;
|
|
94
|
+
return { keys, dbDeclared, unanalyzable };
|
|
95
|
+
}
|
|
96
|
+
/** 하위 호환 — db 커넥션 키만 돌려준다(analyzeConfigDb 위임). */
|
|
97
|
+
export function extractConfigDbKeys(source) {
|
|
98
|
+
return analyzeConfigDb(source).keys;
|
|
44
99
|
}
|
|
45
100
|
function isDefineConfig(e) {
|
|
46
101
|
if (ts.isIdentifier(e) && e.text === 'defineConfig')
|
|
@@ -107,8 +162,24 @@ export async function checkConnections(cwd) {
|
|
|
107
162
|
const configPath = findConfigPath(cwd);
|
|
108
163
|
if (configPath) {
|
|
109
164
|
const src = await readFile(configPath, 'utf8');
|
|
110
|
-
|
|
165
|
+
const analysis = analyzeConfigDb(src);
|
|
166
|
+
for (const k of analysis.keys)
|
|
111
167
|
registered.add(k);
|
|
168
|
+
// W2(결정 135): db 가 선언됐는데 정적으로 못 읽는 형태면 커넥션 검사가
|
|
169
|
+
// 등록 키를 놓쳐 오탐/사각이 생긴다 — "main 만 허용" 으로 조용히 통과하지 말고
|
|
170
|
+
// 안내한다(에러 = 수리 안내서 · §7.5.3).
|
|
171
|
+
if (analysis.dbDeclared && analysis.unanalyzable) {
|
|
172
|
+
issues.push({
|
|
173
|
+
rule: 'connections',
|
|
174
|
+
level: 'warning',
|
|
175
|
+
file: relative(cwd, configPath),
|
|
176
|
+
message: `gaon.config.ts 의 db 설정을 정적으로 읽지 못했습니다 — 커넥션 키 검사가 불완전할 수 있습니다.\n` +
|
|
177
|
+
`읽어낸 키: ${analysis.keys.length ? analysis.keys.join(', ') : '(없음)'}\n` +
|
|
178
|
+
`→ db 를 리터럴 객체로 두세요(키는 리터럴 · 값만 env). 예:\n` +
|
|
179
|
+
` db: { main: { adapter: 'postgres', url: process.env.DATABASE_URL ?? '' } }`,
|
|
180
|
+
detail: { kind: 'unanalyzable-db', keys: analysis.keys },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
112
183
|
}
|
|
113
184
|
const files = [];
|
|
114
185
|
await collectTsFiles(join(cwd, 'domain'), files);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 프로젝트 전체의 §4.5 관계 제약을 검사한다. domain/schema 가 없으면 통과.
|
|
4
|
+
* 스키마 로드 실패(문법 오류 등)는 이 검사만 warning 으로 강등하고 안내한다 —
|
|
5
|
+
* connections(static)·gaon check(typecheck)가 별도로 원인을 짚는다.
|
|
6
|
+
*/
|
|
7
|
+
export declare function checkSchemaRelations(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 스키마 관계 검사 배선 (§4.5 · 결정 134)
|
|
2
|
+
//
|
|
3
|
+
// @gaonjs/data 의 checkCrossConnectionRelations·checkRelationTargets 를 `gaon
|
|
4
|
+
// doctor` 러너에 배선한다. 이 검사들은 이미 data 패키지에 구현+테스트가 완비돼
|
|
5
|
+
// 있었으나 CLI 러너가 호출하지 않아 커넥션을 가로지르는 belongsTo 가 doctor 로
|
|
6
|
+
// 잡히지 않고 배포 후 raw postgres 에러로만 드러났다 — "미구현"이 아니라
|
|
7
|
+
// "배선 안 됨"(§13.9 "단위 green ≠ 개발자 도달" 4번째 사례 · 결정 134).
|
|
8
|
+
//
|
|
9
|
+
// connections 검사(static AST)와 달리 관계 그래프는 TableDef 값(참조 컬럼·db
|
|
10
|
+
// 키·역방향 관계)이 필요하다. 그래서 domain/schema/*.ts 를 실제 로드해 TableDef[]
|
|
11
|
+
// 를 모은다 — `gaon db diff`·`migrate` 와 같은 스키마 로드 경로(scanSchemaDir).
|
|
12
|
+
// 커넥션으로 걸러내지 않고 **전 커넥션 테이블을 함께** 봐야 커넥션을 가로지르는
|
|
13
|
+
// belongsTo 를 검출할 수 있다(resolve.ts scanTables 는 단일 dbKey 로 거르므로 부적합).
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { join, relative } from 'node:path';
|
|
16
|
+
import { scanSchemaDir, checkCrossConnectionRelations, checkRelationTargets, } from '@gaonjs/data';
|
|
17
|
+
import { registerTsResolve } from '../tsResolve.js';
|
|
18
|
+
/** TableDef 판별 — scanSchemaDir 이 준 모듈 네임스페이스에서 table() 산출만 고른다. */
|
|
19
|
+
function isTableDef(v) {
|
|
20
|
+
return (typeof v === 'object' &&
|
|
21
|
+
v !== null &&
|
|
22
|
+
'name' in v &&
|
|
23
|
+
'defs' in v &&
|
|
24
|
+
'db' in v &&
|
|
25
|
+
typeof v.name === 'string');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* domain/schema/ 의 모든 TableDef 를 커넥션 구분 없이 로드하고, 각 테이블이
|
|
29
|
+
* 어느 소스 파일에서 왔는지 함께 기록한다(진단에 파일 위치를 붙이기 위함).
|
|
30
|
+
*/
|
|
31
|
+
async function loadAllTables(cwd, schemaDir) {
|
|
32
|
+
registerTsResolve();
|
|
33
|
+
// outDir 은 import 지정자 계산에만 쓰이고 파일을 쓰지 않는다(resolve.ts 관례).
|
|
34
|
+
const outDir = join(cwd, '.gaon');
|
|
35
|
+
const modules = await scanSchemaDir(schemaDir, outDir);
|
|
36
|
+
const tables = [];
|
|
37
|
+
const fileOf = new Map();
|
|
38
|
+
for (const mod of modules) {
|
|
39
|
+
// importPath 는 .gaon 기준 상대(./../domain/schema/x.js) — 사람이 읽을 프로젝트
|
|
40
|
+
// 상대 경로로 정규화한다(진단 file 필드는 cwd 상대 관례).
|
|
41
|
+
const relFile = importPathToProjectRel(cwd, outDir, mod.importPath);
|
|
42
|
+
for (const [, value] of Object.entries(mod.ns)) {
|
|
43
|
+
if (isTableDef(value)) {
|
|
44
|
+
tables.push(value);
|
|
45
|
+
if (!fileOf.has(value.name))
|
|
46
|
+
fileOf.set(value.name, relFile);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
tables.sort((a, b) => a.name.localeCompare(b.name));
|
|
51
|
+
return { tables, fileOf };
|
|
52
|
+
}
|
|
53
|
+
/** scanSchemaDir 의 importPath(.gaon 기준, .js)를 cwd 상대 .ts 경로로 되돌린다. */
|
|
54
|
+
function importPathToProjectRel(cwd, outDir, importPath) {
|
|
55
|
+
const abs = join(outDir, importPath).replace(/\.js$/, '.ts');
|
|
56
|
+
return relative(cwd, abs).replace(/\\/g, '/');
|
|
57
|
+
}
|
|
58
|
+
/** data 의 Diagnostic 를 doctor 의 DoctorCheck 로 변환한다(§4.5 → RuleReport 정합). */
|
|
59
|
+
function toCheck(d, fileOf, owner) {
|
|
60
|
+
const file = fileOf.get(owner);
|
|
61
|
+
return {
|
|
62
|
+
rule: 'schema-relations',
|
|
63
|
+
level: d.level === 'warning' ? 'warning' : 'error',
|
|
64
|
+
message: d.message,
|
|
65
|
+
...(file ? { file } : {}),
|
|
66
|
+
detail: { code: d.code, table: owner },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 관계 진단이 어느 테이블 소유인지를 message 앞머리(`<table>.<...>`)에서 뽑아
|
|
71
|
+
* 파일을 붙인다. 관계·컬럼 검사 모두 "테이블명.멤버 …" 로 시작한다.
|
|
72
|
+
*/
|
|
73
|
+
function ownerOf(message) {
|
|
74
|
+
const m = message.match(/^([A-Za-z0-9_]+)\./);
|
|
75
|
+
return m ? m[1] : '';
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 프로젝트 전체의 §4.5 관계 제약을 검사한다. domain/schema 가 없으면 통과.
|
|
79
|
+
* 스키마 로드 실패(문법 오류 등)는 이 검사만 warning 으로 강등하고 안내한다 —
|
|
80
|
+
* connections(static)·gaon check(typecheck)가 별도로 원인을 짚는다.
|
|
81
|
+
*/
|
|
82
|
+
export async function checkSchemaRelations(cwd) {
|
|
83
|
+
const schemaDir = join(cwd, 'domain', 'schema');
|
|
84
|
+
if (!existsSync(schemaDir))
|
|
85
|
+
return { rule: 'schema-relations', issues: [] };
|
|
86
|
+
let tables;
|
|
87
|
+
let fileOf;
|
|
88
|
+
try {
|
|
89
|
+
;
|
|
90
|
+
({ tables, fileOf } = await loadAllTables(cwd, schemaDir));
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
94
|
+
return {
|
|
95
|
+
rule: 'schema-relations',
|
|
96
|
+
issues: [
|
|
97
|
+
{
|
|
98
|
+
rule: 'schema-relations',
|
|
99
|
+
level: 'warning',
|
|
100
|
+
message: `스키마 관계 검사를 건너뜁니다 — domain/schema 로드 실패: ${msg}\n` +
|
|
101
|
+
`→ 'gaon check' 로 타입 오류를 먼저 확인하세요(스키마가 컴파일되면 이 검사가 활성화됩니다).`,
|
|
102
|
+
detail: { skipped: true },
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// data 패키지 구현을 그대로 호출한다(재구현 금지 · 결정 134).
|
|
108
|
+
const diags = [
|
|
109
|
+
...checkCrossConnectionRelations(tables),
|
|
110
|
+
...checkRelationTargets(tables),
|
|
111
|
+
];
|
|
112
|
+
const issues = diags.map((d) => toCheck(d, fileOf, ownerOf(d.message)));
|
|
113
|
+
return { rule: 'schema-relations', issues };
|
|
114
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security';
|
|
1
|
+
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations';
|
|
2
2
|
export type DoctorLevel = 'passed' | 'warning' | 'error';
|
|
3
3
|
export interface DoctorCheck {
|
|
4
4
|
readonly rule: DoctorRule;
|
package/dist/doctor/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 25 검사(전체 목록은 AGENTS §2.2 · doctor.ts `ALL_RULES`)가 모두 이 DoctorCheck
|
|
4
4
|
// 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
|
|
5
5
|
// warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
|
|
6
6
|
// errors.length > 0 이면 fail 로 판단한다.
|
package/dist/doctor.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ export type { ResponseKind, ActionUsage } from './doctor/response-mixing.js';
|
|
|
4
4
|
export { inspectControllerSource, checkResponseMixing } from './doctor/response-mixing.js';
|
|
5
5
|
export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one.js';
|
|
6
6
|
export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
7
|
-
export { extractConfigDbKeys, extractKeyUses, checkConnections } from './doctor/connections.js';
|
|
7
|
+
export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
|
|
8
|
+
export { checkSchemaRelations } from './doctor/schema-relations.js';
|
|
8
9
|
export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
|
|
9
10
|
export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
|
|
10
11
|
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
package/dist/doctor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 25 검사를 조립한다:
|
|
5
5
|
* 1) response-mixing (errata E-3 §C · 라이브)
|
|
6
6
|
* 2) n-plus-one (errata E-4 (e))
|
|
7
7
|
* 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
|
|
26
26
|
* 22) page-layout-breakpoint (결정 107 · 페이지 레이아웃 브레이크포인트 직접 사용 = 안내 경고)
|
|
27
27
|
* 23) link-button-nesting (결정 113 · Link 로 Button 감싸기 = <a><button> 중첩 경고)
|
|
28
|
+
* 24) seal-security (결정 121 · seal 클라 배선 · 보안 역전)
|
|
29
|
+
* 25) schema-relations (§4.5 · 결정 134 · 커넥션 가로지르는 belongsTo·관계 · 대상 부재 error)
|
|
28
30
|
*
|
|
29
31
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
30
32
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -41,6 +43,7 @@ import { checkResponseMixing } from './doctor/response-mixing.js';
|
|
|
41
43
|
import { checkNPlusOne } from './doctor/n-plus-one.js';
|
|
42
44
|
import { checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
43
45
|
import { checkConnections } from './doctor/connections.js';
|
|
46
|
+
import { checkSchemaRelations } from './doctor/schema-relations.js';
|
|
44
47
|
import { checkMigrationDiff } from './doctor/migration-diff.js';
|
|
45
48
|
import { checkSharedComposablePurity } from './doctor/shared-composable-purity.js';
|
|
46
49
|
import { checkNoAutoImport } from './doctor/no-auto-import.js';
|
|
@@ -68,7 +71,8 @@ import { FIXERS, FIXER_CAPABILITIES } from './doctor/fixers/index.js';
|
|
|
68
71
|
export { inspectControllerSource, checkResponseMixing } from './doctor/response-mixing.js';
|
|
69
72
|
export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one.js';
|
|
70
73
|
export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
71
|
-
export { extractConfigDbKeys, extractKeyUses, checkConnections } from './doctor/connections.js';
|
|
74
|
+
export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
|
|
75
|
+
export { checkSchemaRelations } from './doctor/schema-relations.js';
|
|
72
76
|
export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
|
|
73
77
|
export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
|
|
74
78
|
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
|
@@ -115,6 +119,7 @@ const ALL_RULES = [
|
|
|
115
119
|
'page-layout-breakpoint',
|
|
116
120
|
'link-button-nesting',
|
|
117
121
|
'seal-security',
|
|
122
|
+
'schema-relations',
|
|
118
123
|
];
|
|
119
124
|
const CHECKERS = {
|
|
120
125
|
'response-mixing': checkResponseMixing,
|
|
@@ -141,6 +146,7 @@ const CHECKERS = {
|
|
|
141
146
|
'page-layout-breakpoint': checkPageLayoutBreakpoint,
|
|
142
147
|
'link-button-nesting': checkLinkButtonNesting,
|
|
143
148
|
'seal-security': checkSealSecurity,
|
|
149
|
+
'schema-relations': checkSchemaRelations,
|
|
144
150
|
};
|
|
145
151
|
/**
|
|
146
152
|
* 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
|
package/dist/index.js
CHANGED
|
@@ -104,7 +104,7 @@ function renderHelp(version = VERSION) {
|
|
|
104
104
|
" gaon gen .gaon 타입 브리지 + api() 런타임 매니페스트만 재생성 (서버·검사 없이 · build 전제 · --json)",
|
|
105
105
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
106
106
|
" gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
|
|
107
|
-
" gaon doctor 정적 검사 (
|
|
107
|
+
" gaon doctor 정적 검사 (25 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계)",
|
|
108
108
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
109
109
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
110
110
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -121,8 +121,8 @@ function renderHelp(version = VERSION) {
|
|
|
121
121
|
" gaon work 워커 프로세스 (잡·리스너·스케줄·아웃박스 · graceful drain)",
|
|
122
122
|
" gaon jobs list --failed DLQ(실패 잡) 목록",
|
|
123
123
|
" gaon jobs retry <id> DLQ 잡 재적재",
|
|
124
|
-
" gaon db diff 스키마 ↔ DB 차이 미리보기 (적용 X ·
|
|
125
|
-
" gaon db migrate db/migrations/*.ts replay + 스키마 diff 적용 + 이력",
|
|
124
|
+
" gaon db diff 스키마 ↔ DB 차이 미리보기 (적용 X · 전 커넥션 순회 · --db <키> 로 단일)",
|
|
125
|
+
" gaon db migrate db/migrations/*.ts replay + 스키마 diff 적용 + 이력 (전 커넥션 순회 · --db <키> 로 단일)",
|
|
126
126
|
" gaon db migrate down 가장 최근 이력 한 건 롤백",
|
|
127
127
|
" gaon db migrate --dry-run 적용 없이 실행 예정 파일 + up SQL 만 출력",
|
|
128
128
|
" gaon db status 마이그레이션 파일 적용/대기 + 스키마 drift",
|
|
@@ -344,7 +344,7 @@ export function runCli(argv, opts = {}) {
|
|
|
344
344
|
if (!sub || !known.includes(sub)) {
|
|
345
345
|
process.stderr.write(` ✗ 알 수 없는 db 서브커맨드: ${sub ?? "(없음)"}\n` +
|
|
346
346
|
` → 지원: gaon db diff | migrate | reset | seed | status\n` +
|
|
347
|
-
` → 옵션: --json · --db <키> · --config <path> · --yes · --dry-run\n`);
|
|
347
|
+
` → 옵션: --json · --db <키>(생략 = 전 커넥션 순회) · --config <path> · --yes · --dry-run\n`);
|
|
348
348
|
process.exitCode = 1;
|
|
349
349
|
return;
|
|
350
350
|
}
|
|
@@ -10,6 +10,14 @@ REDIS_URL=redis://127.0.0.1:6379
|
|
|
10
10
|
# NATS — 실시간·비동기 백본(§7).
|
|
11
11
|
NATS_URL=nats://127.0.0.1:4222
|
|
12
12
|
|
|
13
|
+
# 파일 스토리지(§7 · M8) — docker-compose.yaml 의 minio 서비스와 정합.
|
|
14
|
+
# STORAGE_BUCKET 은 compose 의 createbuckets 가 만드는 dev 버킷명과 같아야 한다.
|
|
15
|
+
# 운영은 R2/S3 endpoint·자격증명으로 교체하고, 버킷은 인프라에서 사전 생성한다.
|
|
16
|
+
STORAGE_ENDPOINT=http://127.0.0.1:9000
|
|
17
|
+
STORAGE_BUCKET={{PROJECT_NAME}}
|
|
18
|
+
STORAGE_ACCESS_KEY={{PROJECT_NAME}}
|
|
19
|
+
STORAGE_SECRET_KEY={{PROJECT_NAME}}_secret
|
|
20
|
+
|
|
13
21
|
# 세션 · 쿠키 서명 비밀 (32자 이상, 운영은 반드시 교체).
|
|
14
22
|
SESSION_SECRET=change-me-to-a-32-char-random-secret!!
|
|
15
23
|
COOKIE_SECRET=change-me-too-32-char-random-secret!!
|
|
@@ -26,6 +26,7 @@ v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구
|
|
|
26
26
|
| 페이지 · 컴포넌트 · 컴포저블 · 레이아웃 · `api()` · bigint key | `agents/frontend.md` |
|
|
27
27
|
| 잡 · 이벤트 · 리스너 · 아웃박스 · 스케줄 | `agents/async.md` |
|
|
28
28
|
| 채널 · 프레즌스 · 허브 | `agents/realtime.md` |
|
|
29
|
+
| 파일 스토리지 (`Storage.put/url` · s3Disk · presigned · CSP 자동 배선) | `agents/storage.md` |
|
|
29
30
|
| 테스트 작성·실행 (실 인프라 · `expectJobProcessed`) | `agents/testing.md` |
|
|
30
31
|
| 보안 기본값 · 탈출구(v-html · raw SQL) 사용 | `agents/security.md` |
|
|
31
32
|
| 페이로드 봉인 (`@gaonjs/seal` · wire/문서/WS 암호화 · 선택 플러그인) | `agents/seal.md` |
|
|
@@ -105,12 +106,12 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
105
106
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
106
107
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
107
108
|
|
|
108
|
-
### 2.2 `gaon doctor` 검사
|
|
109
|
+
### 2.2 `gaon doctor` 검사 25종
|
|
109
110
|
|
|
110
111
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
111
112
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
112
113
|
3. `dependency-direction` — 의존 방향 4규칙 위반
|
|
113
|
-
4. `connections` — 커넥션
|
|
114
|
+
4. `connections` — 스키마·`getConnection` 이 쓰는 커넥션 키가 `gaon.config.ts` 에 등록됐는지 · db 설정 정적 분석(삼항·`??` 지원 · 못 읽으면 안내) (§4.5 · 결정 135)
|
|
114
115
|
5. `migration-diff` — 스키마 vs DB 상태 불일치
|
|
115
116
|
6. `shared-composable-purity` — shared 안 `api`/`pageProps` import (결정 25)
|
|
116
117
|
7. `no-auto-import` — 자동 import 설정 (E-5 §2.4)
|
|
@@ -131,6 +132,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
131
132
|
22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
|
|
132
133
|
23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
|
|
133
134
|
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon check --fix` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
135
|
+
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
|
|
134
136
|
|
|
135
137
|
## 3. 로직 배치 One Way 판단표
|
|
136
138
|
|
|
@@ -219,7 +221,10 @@ gaon doctor # 정적 검사 24종 (§2.2)
|
|
|
219
221
|
|
|
220
222
|
**스케일링 구분** — `serve --workers N`(한 포트 · node:cluster 수직) vs 웹 인스턴스
|
|
221
223
|
여러 대(각각 다른 `PORT` · 허브 뒤 수평) vs `work`(포트 없음 · 프로세스만)는 서로
|
|
222
|
-
다르다.
|
|
224
|
+
다르다. 워커 다중화 시 스케줄 발행=단일 리더 · 소비=워커 분산 계약은
|
|
225
|
+
`agents/async.md` §5 "리더 선출 계약" 이 정본이다(exactly-once 발행 · `gaon serve`
|
|
226
|
+
는 스케줄러 미실행). 로컬 멀티 인스턴스 레시피·구분표는 프레임웍 문서
|
|
227
|
+
`gaonjs.dev` 의 operations 가이드(`docs/guides/operations.md`)를 참고한다.
|
|
223
228
|
|
|
224
229
|
## 5. npm 배포본 — 패키지 → 역할
|
|
225
230
|
|
|
@@ -258,8 +263,9 @@ gaon doctor # 정적 검사 24종 (§2.2)
|
|
|
258
263
|
이력 동결 = `gaondesignv0.16.md`·`v0.15.md` + errata E-1~E-5
|
|
259
264
|
(E-1 파사드명 · E-2 실시간 TCP · E-3 JSON 액션/params · E-4 컬럼·
|
|
260
265
|
체이닝 · E-5 컴포저블·레이아웃).
|
|
261
|
-
-
|
|
262
|
-
|
|
263
|
-
operations · configuration
|
|
266
|
+
- 가이드(프레임웍 문서 · `gaonjs.dev` · 사용자 프로젝트엔 동봉 안 됨):
|
|
267
|
+
getting-started · data · data-flow · serialization · authentication ·
|
|
268
|
+
realtime · async · pipeline · operations · configuration. 작업별 정본은
|
|
269
|
+
위 §0 표의 `agents/*.md`(스캐폴드에 동봉) 를 먼저 읽는다.
|
|
264
270
|
- 프레임웍 구현 저장소의 AI 지침은 `CLAUDE.md` — 이 문서와 대상이
|
|
265
271
|
다르다 (CLAUDE = 프레임웍 구현자용, AGENTS = 프레임웍 사용자용).
|
|
@@ -226,6 +226,23 @@ export default schedule((s) => {
|
|
|
226
226
|
| `s.daily.at('HH:MM', Job)` | 매일 지정 시각 |
|
|
227
227
|
| `s.cron('분 시 일 월 요일', Job)` | 5필드 크론 표현식 |
|
|
228
228
|
|
|
229
|
+
#### 리더 선출 계약 (정본)
|
|
230
|
+
|
|
231
|
+
여러 `gaon work` 인스턴스를 HA 로 띄워도 스케줄이 중복 발행되지 않는 이유:
|
|
232
|
+
|
|
233
|
+
- **스케줄 발행 = 단일 리더** — 워커들이 NATS KV 리스(lease)로 리더 하나를
|
|
234
|
+
선출하고, 그 리더만 `s.every`·`s.cron` 시각에 잡을 **발행**한다. 리더가
|
|
235
|
+
죽으면 리스가 만료돼 다른 워커가 승계한다(active-standby).
|
|
236
|
+
- **소비 = 워커 전체 분산** — 발행된 잡은 NATS JetStream 큐 그룹으로 **모든**
|
|
237
|
+
워커에 로드밸런싱된다. 발행은 1인, 처리는 N인.
|
|
238
|
+
- **exactly-once(발행 기준)** — 한 스케줄 틱은 리더 1인이 한 번만 발행한다.
|
|
239
|
+
잡 자체는 재시도(백오프)가 있으니 **핸들러는 멱등**하게 짠다(같은 잡이 두 번
|
|
240
|
+
처리돼도 안전하게).
|
|
241
|
+
- **`gaon serve` 는 스케줄러를 돌리지 않는다** — 스케줄·리더 선출·아웃박스
|
|
242
|
+
릴레이는 **`gaon work` 전용**이다. 웹 프로세스는 잡을 **발행**만 할 수 있고
|
|
243
|
+
(`.later()`), 처리·스케줄은 워커가 한다. 스케줄이 안 도는 흔한 원인은
|
|
244
|
+
`gaon work` 를 안 띄운 것이다.
|
|
245
|
+
|
|
229
246
|
### 6. 워커 프로세스 (`gaon work`)
|
|
230
247
|
|
|
231
248
|
잡·리스너·스케줄러·아웃박스 릴레이를 한 프로세스로 조립한다. 운영
|
|
@@ -340,11 +340,50 @@ const rows = await Post.query()
|
|
|
340
340
|
### 7. 멀티 DB 커넥션 (v0.15 §4.5)
|
|
341
341
|
|
|
342
342
|
- **키 생략 = main** — 기본 경로는 단일 DB 프로젝트와 완전히 같다.
|
|
343
|
-
- **커넥션을 가로지르는 `belongsTo
|
|
344
|
-
**
|
|
345
|
-
-
|
|
346
|
-
|
|
347
|
-
|
|
343
|
+
- **커넥션을 가로지르는 `belongsTo`·역방향 관계는 금지** — SQL 조인은
|
|
344
|
+
커넥션을 못 넘는다. doctor 의 **schema-relations** 검사(결정 134)가
|
|
345
|
+
`cross-connection-belongsTo`·`cross-connection-relation`·존재하지 않는 관계
|
|
346
|
+
대상을 **에러**로 잡는다(배포 후 raw postgres 에러 대신 `gaon doctor` 에서).
|
|
347
|
+
커넥션 키 등록 정합은 **connections** 검사가 본다.
|
|
348
|
+
- **`service()` 트랜잭션은 단일 커넥션에서만 원자적** — Gaon 은 분산
|
|
349
|
+
트랜잭션을 흉내 내지 않는다(§9 정본).
|
|
350
|
+
- **마이그레이션은 전 커넥션에 걸린다** — `gaon db migrate`(인자 없음)는 등록된
|
|
351
|
+
**모든** 커넥션을 순회 적용한다(결정 139 · One Way — 커넥션 하나를 잊어 빈 채
|
|
352
|
+
배포하는 사고 방지). 한 커넥션만 좁히려면 `gaon db migrate --db legacy`.
|
|
353
|
+
diff·status·seed 도 같은 정책(생략=전 커넥션 · `--db <키>`=단일). reset(파괴적)만
|
|
354
|
+
항상 단일이다.
|
|
355
|
+
|
|
356
|
+
#### 두 DB 에 걸친 쓰기 — `afterCommit` 로 잇는다 (정본)
|
|
357
|
+
|
|
358
|
+
커넥션을 가로지르는 쓰기(예: main 에 주문 저장 → analytics 에 집계 기록)는
|
|
359
|
+
**한 트랜잭션으로 묶을 수 없다**. main 커넥션 트랜잭션을 **먼저 커밋**하고,
|
|
360
|
+
성공한 뒤에만 `afterCommit` 에서 보조 커넥션에 쓴다 — 실패해도 main 은 이미
|
|
361
|
+
안전하고, 재시도·보정은 잡/아웃박스로 다룬다.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
// domain/services/PlaceOrder.ts — main 커밋 성공 뒤에만 analytics 기록
|
|
365
|
+
import { service, afterCommit } from 'gaonjs/service'
|
|
366
|
+
import { getConnection } from 'gaonjs/data'
|
|
367
|
+
|
|
368
|
+
export const placeOrder = service(async (input: { userId: string; total: number }) => {
|
|
369
|
+
const order = await Order.create({ userId: input.userId, total: input.total }) // main 트랜잭션
|
|
370
|
+
|
|
371
|
+
// 커넥션을 가로지르는 쓰기는 트랜잭션 밖 — 커밋 성공 뒤에만.
|
|
372
|
+
afterCommit(async () => {
|
|
373
|
+
await getConnection('analytics')
|
|
374
|
+
.insertInto('order_stats')
|
|
375
|
+
.values({ orderId: order.id, total: input.total })
|
|
376
|
+
.execute()
|
|
377
|
+
})
|
|
378
|
+
return order
|
|
379
|
+
})
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
- **왜 트랜잭션에 안 넣나** — 두 커넥션에 걸친 원자성은 불가능하다. main 을
|
|
383
|
+
진실의 원천으로 커밋하고 analytics 는 파생으로 뒤따르게 한다(정합이 중요하면
|
|
384
|
+
아웃박스/보상 트랜잭션으로 격상).
|
|
385
|
+
- **왜 `afterCommit`** — main 이 롤백되면 analytics 기록도 일어나지 않아야 한다.
|
|
386
|
+
`afterCommit` 은 커밋이 성공한 경우에만 콜백을 돈다(§9 · `agents/async.md`).
|
|
348
387
|
|
|
349
388
|
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts:601-622`)
|
|
350
389
|
|
|
@@ -504,8 +543,9 @@ export const PublishPost = service(async (postId: bigint) => {
|
|
|
504
543
|
롤백. 본문 안의 모델 호출은 코드 변경 없이 트랜잭션에 합류한다
|
|
505
544
|
(AsyncLocalStorage 전파). `{ transaction: false }` 로 해제.
|
|
506
545
|
- **커넥션** — `{ db: '키' }` (생략 = main). 트랜잭션은 **단일
|
|
507
|
-
커넥션에서만 원자적** (§7) — 다른
|
|
508
|
-
|
|
546
|
+
커넥션에서만 원자적** (§7) — 다른 키에 걸친 쓰기는 트랜잭션 밖에서
|
|
547
|
+
`afterCommit` 으로 잇는다(§7 "두 DB 에 걸친 쓰기"). 커넥션 키 등록
|
|
548
|
+
정합은 doctor **connections**, 관계 경계는 **schema-relations** 가 본다.
|
|
509
549
|
- **중첩** — 같은 커넥션 키의 서비스가 서비스를 부르면 바깥
|
|
510
550
|
트랜잭션에 합류한다 (중첩 BEGIN 없음 — 전체가 한 단위).
|
|
511
551
|
- **`afterCommit(fn)`** — 커밋 성공 뒤에만 실행 (롤백 시 실행 안 됨).
|
|
@@ -517,14 +557,18 @@ export const PublishPost = service(async (postId: bigint) => {
|
|
|
517
557
|
### 10. 마이그레이션 — 파일 리플레이 + 스키마 diff (합성형 · 결정 39)
|
|
518
558
|
|
|
519
559
|
```bash
|
|
520
|
-
gaon db diff # 스키마(domain/schema/*.ts) ↔ 실제 DB 차이 미리보기 (적용 X)
|
|
521
|
-
gaon db migrate # db/migrations/*.ts replay → 스키마 diff 적용 + _gaon_migrations 이력
|
|
560
|
+
gaon db diff # 스키마(domain/schema/*.ts) ↔ 실제 DB 차이 미리보기 (적용 X · 전 커넥션)
|
|
561
|
+
gaon db migrate # db/migrations/*.ts replay → 스키마 diff 적용 + _gaon_migrations 이력 (전 커넥션)
|
|
522
562
|
gaon db migrate down # 가장 최근 이력 1건 롤백
|
|
523
|
-
gaon db
|
|
524
|
-
gaon db
|
|
525
|
-
gaon db
|
|
563
|
+
gaon db migrate --db analytics # 한 커넥션만 좁혀 적용
|
|
564
|
+
gaon db status # 마이그레이션 파일 적용/대기 + 스키마 drift (전 커넥션)
|
|
565
|
+
gaon db reset --yes # 초기화 (dev · production 거부 · 항상 단일 커넥션 --db)
|
|
566
|
+
gaon db seed # domain/seed.ts 실행 (전 커넥션)
|
|
526
567
|
```
|
|
527
568
|
|
|
569
|
+
diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션을 순회**한다
|
|
570
|
+
(결정 139). `gaon db diff`(내부 `_gaon_*` 테이블은 계획에서 제외 · 결정 138).
|
|
571
|
+
|
|
528
572
|
**기본은 스키마 우선이다.** `domain/schema/*.ts` 를 고치고 `gaon db migrate`
|
|
529
573
|
하면 diff 가 차이를 계산해 반영한다. 여기에 **손작성 마이그레이션 파일**이
|
|
530
574
|
1급으로 합쳐진다(결정 39 · 합성형): `migrate` 는 ① `db/migrations/*.ts` 를
|
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
realtime 허용). 끄거나 조정은 `createApp({ security: { securityHeaders: … } })`
|
|
33
33
|
— `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
|
|
34
34
|
`helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
|
|
35
|
+
- **스토리지 오리진 CSP 자동 배선 (결정 131)** — `gaon.config.ts` 의 storage(S3/R2/MinIO)
|
|
36
|
+
설정이 있으면 코어가 그 오리진을 CSP 의 **img-src**(스토리지 이미지 `<img>` 표시)·
|
|
37
|
+
**connect-src**(브라우저 직접 presigned 업로드/다운로드)에 자동으로 더한다(seal→
|
|
38
|
+
`wasm-unsafe-eval`(결정 124)과 동형). 그래서 스토리지 이미지·직접 업로드를 쓰겠다고
|
|
39
|
+
**CSP 를 손으로 넓히거나 `contentSecurityPolicy: false` 로 끄지 말 것** — 오리진은
|
|
40
|
+
storage 설정에서 파생돼 자동으로 허용된다. 오리진은 `endpoint`·`publicUrl` 의
|
|
41
|
+
scheme+host(+port)만 뽑는다(비절대 URL 이면 부팅 에러 + 수리 안내).
|
|
35
42
|
|
|
36
43
|
### 2. 세션·CSRF·JWT
|
|
37
44
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# agents/storage.md — 파일 스토리지 (`Storage` · 디스크 · presigned)
|
|
2
|
+
|
|
3
|
+
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
|
+
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·표·예시의 정본은 이 파일이다.
|
|
5
|
+
> 대상 패키지: `@gaonjs/storage` (파사드 import 는 `gaonjs/storage`).
|
|
6
|
+
|
|
7
|
+
## 정본 규칙
|
|
8
|
+
|
|
9
|
+
### 1. 하나의 API, 여러 디스크
|
|
10
|
+
|
|
11
|
+
파일은 `Storage` 파사드로 저장·조회한다 — 로컬(`storage/`)이든 S3 호환
|
|
12
|
+
(Cloudflare R2·MinIO·AWS S3)이든 **같은 코드**가 돈다. 디스크는
|
|
13
|
+
`gaon.config.ts` 의 `storage` 로 선언하고, endpoint 만 바꾸면 dev(MinIO) →
|
|
14
|
+
운영(R2/S3) 로 옮겨간다.
|
|
15
|
+
|
|
16
|
+
| 표면 | 시그니처 | 비고 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| 저장 | `Storage.put(key, body, { contentType? })` | body = Buffer·string·Uint8Array |
|
|
19
|
+
| 조회 | `Storage.get(key): Promise<Buffer \| null>` | 없으면 null |
|
|
20
|
+
| 삭제 | `Storage.delete(key)` | 멱등 |
|
|
21
|
+
| 존재 | `Storage.exists(key): Promise<boolean>` | |
|
|
22
|
+
| URL | `Storage.url(key, { expiresIn? }): Promise<string>` | 공개 버킷/CDN = 공개 URL · 아니면 presigned |
|
|
23
|
+
| 디스크 선택 | `Storage.disk('s3').put(...)` | 기본 디스크 외 다른 디스크로 |
|
|
24
|
+
|
|
25
|
+
- **URL 은 `Storage.url()` 한 곳**이다 — `publicUrl`(공개 버킷·CDN·R2 public)이
|
|
26
|
+
있으면 `${publicUrl}/${key}`, 없으면 만료 있는 **presigned URL** 을 만든다.
|
|
27
|
+
`expiresIn`(초)로 만료를 조절한다. 존재하지 않는 `Attachment.urlFor` 같은
|
|
28
|
+
헬퍼를 만들지 말 것 — 표면은 `Storage.url()` 뿐이다.
|
|
29
|
+
- **키는 경로**다(`avatars/${user.id}.png`). 앞 슬래시는 정규화된다.
|
|
30
|
+
|
|
31
|
+
### 2. 설정 (`gaon.config.ts`)
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
storage: process.env.STORAGE_ENDPOINT
|
|
35
|
+
? {
|
|
36
|
+
default: 'main',
|
|
37
|
+
disks: {
|
|
38
|
+
main: {
|
|
39
|
+
driver: 's3', // 's3' | 'local'
|
|
40
|
+
bucket: process.env.STORAGE_BUCKET ?? 'myapp',
|
|
41
|
+
endpoint: process.env.STORAGE_ENDPOINT, // MinIO/R2 = 필수 · AWS S3 = 생략
|
|
42
|
+
accessKeyId: process.env.STORAGE_ACCESS_KEY,
|
|
43
|
+
secretAccessKey: process.env.STORAGE_SECRET_KEY,
|
|
44
|
+
// publicUrl: 'https://cdn.example.com', // 있으면 url()이 공개 URL
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
: undefined,
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
|
|
52
|
+
(결정 132 · zero-config). `cp .env.example .env && gaon dev` → 우회 0.
|
|
53
|
+
- 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/uploads' }`.
|
|
54
|
+
- 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
|
|
55
|
+
만 env 로 바꾼다.
|
|
56
|
+
|
|
57
|
+
### 3. 업로드 (multipart)
|
|
58
|
+
|
|
59
|
+
컨트롤러에서 `this.file('필드명')` 으로 업로드 파일을 받아 그대로 저장한다:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// apps/web/controllers/profile.ts
|
|
63
|
+
export default controller({
|
|
64
|
+
async updateAvatar() {
|
|
65
|
+
const f = this.file('avatar') // UploadedFile | undefined
|
|
66
|
+
if (!f) return this.back().withErrors({ avatar: '파일이 필요합니다.' })
|
|
67
|
+
const key = `avatars/${this.auth.user!.id}.png`
|
|
68
|
+
await Storage.put(key, f.buffer, { contentType: f.mimetype })
|
|
69
|
+
return this.redirect('/profile')
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **멀티파트 폼의 CSRF 는 `x-csrf-token` 헤더 전용**이다(결정 133 · 구조적).
|
|
75
|
+
`useForm(...).post(url, { headers: { 'x-csrf-token': shared.csrf } })` 로 보낸다 —
|
|
76
|
+
바디 `_csrf` 는 멀티파트에서 안 걸린다(상세는 `agents/web.md` §3).
|
|
77
|
+
|
|
78
|
+
### 4. 스토리지 오리진 CSP 자동 배선 (결정 131)
|
|
79
|
+
|
|
80
|
+
`storage` 에 s3 디스크가 있으면 프레임웍이 그 오리진을 **CSP 에 자동 배선**한다 —
|
|
81
|
+
`endpoint` 는 `connect-src`(브라우저 직접 presigned PUT/GET)와 `img-src` 에,
|
|
82
|
+
`publicUrl` 은 `img-src` 에 붙는다. 이미지 표시·직접 업로드가 CSP 로 막히지
|
|
83
|
+
않으므로 **CSP 를 손으로 넓히지 말 것**. 오리진은 scheme+host+port 만 잡는다.
|
|
84
|
+
|
|
85
|
+
### 5. 스토리지를 쓰는 잡·테스트
|
|
86
|
+
|
|
87
|
+
- 무거운 처리(썸네일·외부 업로드)는 컨트롤러 인라인 대신 `domain/jobs/` 잡으로
|
|
88
|
+
뺀다(`agents/async.md` 판단표 · doctor `async-offload` 경고). 잡·워커(`gaon work`)도
|
|
89
|
+
`wireDomain` 으로 스토리지가 배선돼 있어 `Storage.*` 가 그대로 돈다(결정 129).
|
|
90
|
+
- 테스트에서도 `gaon test` 하네스가 스토리지를 배선한다(결정 136) — s3 버킷은
|
|
91
|
+
`<bucket>-test`, 로컬 root 는 `<root>-test` 로 격리된다(`<db>_test` 대칭 ·
|
|
92
|
+
`agents/testing.md`). 스토리지 잡 테스트는 스캐폴드 `test/setup.ts` 그대로 통과한다.
|
|
93
|
+
|
|
94
|
+
## 알려진 함정
|
|
95
|
+
|
|
96
|
+
- **존재하지 않는 API 를 상상하지 말 것** — 파일 URL 은 `Storage.url()`,
|
|
97
|
+
저장은 `Storage.put()` 뿐이다. `Attachment.urlFor`·`Storage.signedUrl` 등은 없다.
|
|
98
|
+
- **`Storage.url()` 은 async** 다 — `await` 를 빠뜨리면 `[object Promise]` 가 렌더된다.
|
|
99
|
+
- **버킷 미준비 = `NoSuchBucket`** — dev 는 compose `createbuckets` 가, 운영은
|
|
100
|
+
인프라가 버킷을 만든다. 프레임웍은 런타임에 버킷을 만들지 않는다(결정 132).
|
|
101
|
+
- **멀티파트 업로드를 일반 폼처럼 `_csrf` 바디 필드로 보내면 403** — 헤더로
|
|
102
|
+
옮긴다(결정 133).
|
|
103
|
+
|
|
104
|
+
## 관련 결정 번호
|
|
105
|
+
|
|
106
|
+
- 결정 131 — 스토리지 오리진 CSP 자동 배선(img-src·connect-src).
|
|
107
|
+
- 결정 132 — dev 버킷 zero-config(compose `createbuckets` · 런타임 버킷 생성 안 함).
|
|
108
|
+
- 결정 133 — 멀티파트 CSRF = `x-csrf-token` 헤더 전용(구조적).
|
|
109
|
+
- 결정 129 — `gaon work` 도 `wireDomain` 으로 스토리지·메일 배선(운영 워커).
|
|
110
|
+
- 결정 136 — `gaon test` 하네스가 스토리지·메일을 테스트 격리 값으로 배선.
|
|
@@ -92,22 +92,36 @@ describe('SendWelcomeMail (실 NATS JetStream)', () => {
|
|
|
92
92
|
DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test` 와 스캐폴드
|
|
93
93
|
`test/setup.ts` 가 The One Way 를 제공한다:
|
|
94
94
|
|
|
95
|
-
- `gaon test` 가 테스트 전용 DB(`<db>_test`)를 만들고 마이그레이션한다.
|
|
96
|
-
- `test/setup.ts` 가 그 DB
|
|
97
|
-
|
|
95
|
+
- `gaon test` 가 각 커넥션의 테스트 전용 DB(`<db>_test`)를 만들고 마이그레이션한다.
|
|
96
|
+
- `test/setup.ts` 가 그 DB 들에 붙고(`connectTestDatabase`) 매 테스트 뒤
|
|
97
|
+
**등록된 모든 커넥션**의 테이블을 비운다(`truncateAllConnections`) — 새 테스트는
|
|
98
|
+
항상 빈 DB 에서 시작한다.
|
|
99
|
+
- `connectTestDatabase` 는 DB 뿐 아니라 **스토리지·메일·i18n 도 배선**한다(결정 136)
|
|
100
|
+
— serve·work 와 같은 경로(`wireDomain`)라, 스토리지·메일을 쓰는 잡·서비스가
|
|
101
|
+
테스트에서도 그대로 돈다. 스토리지 s3 버킷은 `<bucket>-test`, 로컬 root 는
|
|
102
|
+
`<root>-test` 로 격리되고(compose `createbuckets` 가 `<PROJECT>-test` 버킷을 만든다),
|
|
103
|
+
메일은 MailPit(캡처 sink) 그대로다.
|
|
98
104
|
|
|
99
105
|
스캐폴드가 심어 주는 `test/setup.ts`(수정 불필요):
|
|
100
106
|
|
|
101
107
|
```ts
|
|
102
108
|
import { afterAll, afterEach, beforeAll } from 'vitest'
|
|
103
|
-
import { connectTestDatabase,
|
|
109
|
+
import { connectTestDatabase, truncateAllConnections, type TestDbHandle } from 'gaonjs/testing'
|
|
104
110
|
|
|
105
111
|
let handle: TestDbHandle
|
|
106
112
|
beforeAll(async () => { handle = await connectTestDatabase() })
|
|
107
|
-
afterEach(async () => { await
|
|
113
|
+
afterEach(async () => { await truncateAllConnections() }) // 보조 커넥션(§4.5)까지 전부 격리
|
|
108
114
|
afterAll(async () => { await handle?.close() })
|
|
109
115
|
```
|
|
110
116
|
|
|
117
|
+
- **`truncateAllConnections()` vs `truncateAll('키')`** — 전자는 등록된 **모든**
|
|
118
|
+
커넥션을 순회한다(멀티 커넥션 격리 · 결정 137). 특정 커넥션만 비우려면
|
|
119
|
+
`truncateAll('analytics')` 로 좁힌다. `truncateAll()`(인자 생략)은 main 만 비우므로
|
|
120
|
+
보조 커넥션이 있는 프로젝트는 `truncateAllConnections()` 를 쓴다.
|
|
121
|
+
- **타임아웃** — 실 인프라 왕복(DB·NATS·스토리지)이 기본 전제라 스캐폴드
|
|
122
|
+
`vitest.config.ts` 는 `testTimeout: 15000`(hookTimeout 포함)으로 둔다 — vitest 기본
|
|
123
|
+
5s 는 `expectJobProcessed`(10s 대기) 같은 e2e 를 그대로 타임아웃 낸다(결정 137).
|
|
124
|
+
|
|
111
125
|
그러면 테스트는 격리 코드 없이 모델·서비스를 그대로 부른다:
|
|
112
126
|
|
|
113
127
|
```ts
|
|
@@ -152,6 +152,34 @@ export default controller({
|
|
|
152
152
|
①이 검증까지 주므로 **모델이 있으면 ①을 먼저 고른다**. ②는 로그인 폼처럼
|
|
153
153
|
전용 테이블이 없는 입력의 탈출구다.
|
|
154
154
|
|
|
155
|
+
**멀티파트 업로드 + CSRF — 토큰은 `x-csrf-token` 헤더로만 (결정 133):**
|
|
156
|
+
|
|
157
|
+
`this.file()` 업로드(멀티파트)의 CSRF 토큰은 **`x-csrf-token` 헤더**로 보낸다.
|
|
158
|
+
멀티파트는 `parts()` 스트리밍이라 CSRF 검사(preHandler) 시점에 **바디가 아직
|
|
159
|
+
파싱되지 않아** 폼 필드 `_csrf` 가 검사에 잡히지 않는다(구조적 한계 · 디스패처가
|
|
160
|
+
handler 안에서 파싱). 일반 폼(JSON/urlencoded)의 `_csrf` 바디 폴백은 멀티파트엔
|
|
161
|
+
통하지 않는다. 파일이 있으면 `useForm` 이 자동으로 multipart 로 보내므로, 업로드
|
|
162
|
+
제출은 **반드시 헤더**로 토큰을 실어야 한다.
|
|
163
|
+
|
|
164
|
+
```vue
|
|
165
|
+
<script setup lang="ts">
|
|
166
|
+
import { useForm, useShared } from 'gaonjs/vue'
|
|
167
|
+
const shared = useShared() // csrf 는 자동 주입 공유 prop (결정 116)
|
|
168
|
+
const form = useForm({ avatar: null as File | null })
|
|
169
|
+
|
|
170
|
+
function submit() {
|
|
171
|
+
// 파일이 있으면 multipart — csrf 는 x-csrf-token 헤더로(바디 _csrf 는 안 걸림).
|
|
172
|
+
form.post('/uploads', { headers: { 'x-csrf-token': shared.csrf } })
|
|
173
|
+
}
|
|
174
|
+
</script>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- **알려진 함정:** 업로드 폼을 일반 폼처럼 `useForm({ avatar, _csrf: shared.csrf })`
|
|
178
|
+
로 짜면 `_csrf` 가 멀티파트 필드로 들어가 **검사 시점에 없어 403** 이 난다. 서버는
|
|
179
|
+
이 경우 "→ x-csrf-token 헤더로 보내라" 는 수리 안내와 함께 403 을 돌려준다.
|
|
180
|
+
- 비멀티파트 폼은 지금처럼 `_csrf: shared.csrf` 바디 필드로 그대로 보낸다(§4 로그인
|
|
181
|
+
예시). 멀티파트일 때만 헤더가 유일 경로다.
|
|
182
|
+
|
|
155
183
|
### 4. 데이터 경로 판단 — 루트 판단표가 정본
|
|
156
184
|
|
|
157
185
|
데이터가 필요할 때는 루트 `AGENTS.md` 의 데이터 경로 4종 판단표를 따른다
|
|
@@ -440,6 +468,7 @@ export default controller({
|
|
|
440
468
|
| 결정 117 | render props 에 예약 공유 키 = 컴파일 에러 + 런타임 방어(자동 주입값 조용한 덮어쓰기 금지 · §4.2) |
|
|
441
469
|
| 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
|
|
442
470
|
| 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
|
|
471
|
+
| 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내) |
|
|
443
472
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|
|
444
473
|
|
|
445
474
|
## `@gaonjs/seal` 켠 앱
|
|
@@ -77,3 +77,23 @@ services:
|
|
|
77
77
|
interval: 2s
|
|
78
78
|
timeout: 3s
|
|
79
79
|
retries: 30
|
|
80
|
+
|
|
81
|
+
# 개발 버킷 생성 — this.file() → Storage.put() 이 첫 업로드부터 돌게 한다.
|
|
82
|
+
# MinIO 서버만 띄우면 버킷이 없어 첫 업로드가 NoSuchBucket 으로 실패한다 —
|
|
83
|
+
# minio 헬스 후 내장 mc 로 dev 버킷(.env 의 STORAGE_BUCKET = 기본 {{PROJECT_NAME}})과
|
|
84
|
+
# 테스트 버킷({{PROJECT_NAME}}-test)을 만들고 종료한다(`--ignore-existing` 라 재기동에
|
|
85
|
+
# 멱등). 테스트 버킷은 `gaon test` 하네스가 스토리지를 `<bucket>-test` 로 격리할 때
|
|
86
|
+
# 쓴다(결정 136 · <db>_test 대칭). minio 서버 이미지에 mc 가 이미 들어 있어 별도
|
|
87
|
+
# 이미지(minio/mc)를 받지 않는다(첫 gaon dev 가 더 빠름). 운영(R2/S3)은 인프라에서
|
|
88
|
+
# 버킷을 사전 생성한다(앱 밖 관심사) — 이 컨테이너는 dev·테스트 전용.
|
|
89
|
+
createbuckets:
|
|
90
|
+
image: minio/minio:latest
|
|
91
|
+
depends_on:
|
|
92
|
+
minio:
|
|
93
|
+
condition: service_healthy
|
|
94
|
+
entrypoint: >
|
|
95
|
+
/bin/sh -c "
|
|
96
|
+
mc alias set local http://minio:9000 {{PROJECT_NAME}} {{PROJECT_NAME}}_secret &&
|
|
97
|
+
mc mb --ignore-existing local/{{PROJECT_NAME}} local/{{PROJECT_NAME}}-test
|
|
98
|
+
"
|
|
99
|
+
restart: "no"
|
|
@@ -4,6 +4,10 @@ import { defineConfig } from 'gaonjs/config'
|
|
|
4
4
|
|
|
5
5
|
export default defineConfig({
|
|
6
6
|
// DB — main 커넥션. 스키마에서 { db: '키' } 로 다른 커넥션에 붙일 수 있다(§4.5).
|
|
7
|
+
// env 미설정이면 배터리를 배선하지 않는다(redis·nats·storage 와 동형). 삼항이어도
|
|
8
|
+
// doctor 의 커넥션·관계 검사가 참 분기의 키(main)를 정적으로 읽어 §4.5 가드레일이
|
|
9
|
+
// 작동한다(결정 135). 커넥션을 늘리려면 참 분기에 키를 더 추가한다:
|
|
10
|
+
// analytics: { adapter: 'postgres', url: process.env.ANALYTICS_URL ?? '' }.
|
|
7
11
|
db: process.env.DATABASE_URL
|
|
8
12
|
? {
|
|
9
13
|
main: {
|
|
@@ -19,6 +23,23 @@ export default defineConfig({
|
|
|
19
23
|
// NATS — 실시간·비동기 백본(§7). broadcast 전용(errata E-2).
|
|
20
24
|
nats: process.env.NATS_URL ? { url: process.env.NATS_URL } : undefined,
|
|
21
25
|
|
|
26
|
+
// 파일 스토리지(§7 · M8). S3 호환(dev = MinIO · 운영 = R2/S3) — endpoint 만 바꾸면
|
|
27
|
+
// 같은 코드가 돈다. dev 버킷은 compose 의 createbuckets 가 만든다(첫 업로드부터 동작).
|
|
28
|
+
storage: process.env.STORAGE_ENDPOINT
|
|
29
|
+
? {
|
|
30
|
+
default: 'main',
|
|
31
|
+
disks: {
|
|
32
|
+
main: {
|
|
33
|
+
driver: 's3',
|
|
34
|
+
bucket: process.env.STORAGE_BUCKET ?? '{{PROJECT_NAME}}',
|
|
35
|
+
endpoint: process.env.STORAGE_ENDPOINT,
|
|
36
|
+
accessKeyId: process.env.STORAGE_ACCESS_KEY,
|
|
37
|
+
secretAccessKey: process.env.STORAGE_SECRET_KEY,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
: undefined,
|
|
42
|
+
|
|
22
43
|
// 웹 서버 리슨 옵션. --port · env PORT 로 덮을 수 있다.
|
|
23
44
|
web: {
|
|
24
45
|
port: process.env.PORT ? Number(process.env.PORT) : 3000,
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
// test/setup.ts — 테스트 격리 부트스트랩 (§9 실 인프라 · 결정 111).
|
|
1
|
+
// test/setup.ts — 테스트 격리 부트스트랩 (§9 실 인프라 · 결정 111·137).
|
|
2
2
|
//
|
|
3
|
-
// `gaon test` 가 테스트 전용 DB(<db>_test)를 만들고 마이그레이션한 뒤
|
|
4
|
-
// 돌린다. 이 파일이 그 DB
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// `gaon test` 가 각 커넥션의 테스트 전용 DB(<db>_test)를 만들고 마이그레이션한 뒤
|
|
4
|
+
// vitest 를 돌린다. 이 파일이 그 DB 들에 붙고(connectTestDatabase — 스토리지·메일도
|
|
5
|
+
// 테스트 격리 값으로 배선), 매 테스트 뒤 등록된 **모든** 커넥션의 테이블을 비운다
|
|
6
|
+
// (truncateAllConnections). 트랜잭션 롤백이 아니라 truncate 인 이유: service() 는
|
|
7
|
+
// 실제 COMMIT 을 해서 바깥 트랜잭션으로 되돌릴 수 없다(결정 111 · agents/testing.md).
|
|
8
|
+
// truncateAll(main 만)이 아니라 truncateAllConnections 인 이유: 보조 커넥션(§4.5)이
|
|
9
|
+
// 비워지지 않으면 상태가 다음 테스트로 새어 조용한 거짓 실패가 난다(결정 137).
|
|
7
10
|
//
|
|
8
11
|
// 목업·인메모리 금지(§9) — 실 DB 로만 검증한다.
|
|
9
12
|
import { afterAll, afterEach, beforeAll } from 'vitest'
|
|
10
|
-
import { connectTestDatabase,
|
|
13
|
+
import { connectTestDatabase, truncateAllConnections, type TestDbHandle } from 'gaonjs/testing'
|
|
11
14
|
|
|
12
15
|
let handle: TestDbHandle
|
|
13
16
|
|
|
@@ -16,7 +19,7 @@ beforeAll(async () => {
|
|
|
16
19
|
})
|
|
17
20
|
|
|
18
21
|
afterEach(async () => {
|
|
19
|
-
await
|
|
22
|
+
await truncateAllConnections()
|
|
20
23
|
})
|
|
21
24
|
|
|
22
25
|
afterAll(async () => {
|
|
@@ -19,5 +19,10 @@ export default defineConfig({
|
|
|
19
19
|
],
|
|
20
20
|
// 실 DB 를 공유하는 통합 테스트 격리 — 파일 병렬 금지(§9 · 결정 111).
|
|
21
21
|
fileParallelism: false,
|
|
22
|
+
// 실 인프라(DB·NATS·스토리지) 왕복이 기본 전제라 vitest 기본 5s 는 너무 짧다 —
|
|
23
|
+
// 잡 발행→워커 처리(expectJobProcessed 10s 대기) 같은 e2e 가 그대로 타임아웃 난다.
|
|
24
|
+
// 15s 로 넉넉히 둔다(결정 137 · One Way — 실 인프라 왕복에 맞춘 기본값).
|
|
25
|
+
testTimeout: 15000,
|
|
26
|
+
hookTimeout: 15000,
|
|
22
27
|
},
|
|
23
28
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
30
|
"@gaonjs/async": "0.8.0",
|
|
31
|
-
"@gaonjs/config": "0.10.0",
|
|
32
|
-
"@gaonjs/data": "0.13.1",
|
|
33
31
|
"@gaonjs/core": "0.2.1",
|
|
32
|
+
"@gaonjs/config": "0.12.0",
|
|
33
|
+
"@gaonjs/web": "0.14.0",
|
|
34
34
|
"@gaonjs/mail": "0.1.3",
|
|
35
|
-
"@gaonjs/
|
|
35
|
+
"@gaonjs/data": "0.14.0"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|