@gaonjs/cli 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/check.d.ts +10 -1
- package/dist/commands/check.js +10 -6
- package/dist/commands/dev.js +2 -0
- package/dist/commands/gen.js +4 -2
- package/dist/db/projectData.d.ts +14 -0
- package/dist/db/projectData.js +51 -0
- package/dist/db.js +38 -2
- package/dist/dev.d.ts +11 -1
- package/dist/dev.js +29 -1
- package/dist/generate.d.ts +24 -2
- package/dist/generate.js +112 -34
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -5
- package/dist/messages-gen.d.ts +6 -0
- package/dist/messages-gen.js +24 -0
- package/dist/templates/auth/Login.vue.tpl +1 -4
- package/dist/templates/auth/dashboard.secure.controller.ts.tpl +14 -0
- package/dist/templates/project/.env.example.tpl +6 -0
- package/dist/templates/project/AGENTS.md.tpl +3 -1
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/async.md.tpl +37 -0
- package/dist/templates/project/agents/data.md.tpl +70 -2
- package/dist/templates/project/agents/frontend.md.tpl +3 -1
- package/dist/templates/project/agents/i18n.md.tpl +107 -0
- package/dist/templates/project/agents/mail.md.tpl +92 -0
- package/dist/templates/project/agents/realtime.md.tpl +8 -0
- package/dist/templates/project/agents/security.md.tpl +56 -10
- package/dist/templates/project/agents/testing.md.tpl +43 -0
- package/dist/templates/project/agents/web.md.tpl +54 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +20 -1
- package/dist/templates/project/gaon.config.ts.tpl +14 -0
- package/package.json +7 -6
package/dist/generate.js
CHANGED
|
@@ -16,6 +16,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
16
16
|
import { dirname, join, resolve } from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import { authUiKitFiles, writeUiKitFiles } from './uikit.js';
|
|
19
|
+
/** 결정 155: 이 스캐폴드가 공개 회원가입을 포함하는가(web 기본 O · 비-web 은 --public 시만). */
|
|
20
|
+
function includesPublicRegistration(opts) {
|
|
21
|
+
const app = opts.app ?? 'web';
|
|
22
|
+
return app === 'web' || opts.public === true;
|
|
23
|
+
}
|
|
19
24
|
// ── 템플릿 로드·치환 ───────────────────────────────────────────
|
|
20
25
|
// 템플릿은 이 모듈과 같은 위치의 templates/auth/ 에 있다(빌드가 dist 로 복사).
|
|
21
26
|
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'auth');
|
|
@@ -42,46 +47,94 @@ function sessionSecretEnvFor(app) {
|
|
|
42
47
|
function devSessionSecretFor(app) {
|
|
43
48
|
return `dev-only-session-secret-${app}-change-me-now!!`;
|
|
44
49
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
/** .env(.example)에 시드할 앱별 세션 secret 플레이스홀더 값(32자 이상 · 운영은 교체). */
|
|
51
|
+
function envSecretPlaceholderFor(app) {
|
|
52
|
+
return `change-me-to-a-32-char-${app}-session-secret!!`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 결정 155·142: `g auth --app <앱>` 이 앱별 세션 secret 환경변수를 .env·.env.example
|
|
56
|
+
* 에도 시드한다. web 은 SESSION_SECRET 이 프로젝트 스캐폴드(.env.example.tpl)에 이미
|
|
57
|
+
* 있어 no-op 이지만, 비-web 앱(admin 등)은 이 키가 어디에도 없어 app.config 의
|
|
58
|
+
* `process.env.ADMIN_SESSION_SECRET ?? '공개 고정 dev secret'` 이 조용히 폴백으로
|
|
59
|
+
* 돌아 세션 위조 표면이 된다. env 파일에 키를 심어 사용자가 설정해야 함을 드러낸다
|
|
60
|
+
* (앱별 세션 완전 분리를 env 층까지 확장).
|
|
61
|
+
*
|
|
62
|
+
* 이미 그 키가 있으면 건드리지 않는다(멱등). 파일이 없으면 만들지 않는다 — 스캐폴드가
|
|
63
|
+
* .env 를 새로 만드는 관례가 없어(사용자가 cp .env.example .env), 존재하는 파일만 패치한다.
|
|
64
|
+
* 반환: 실제로 키를 추가한 상대 경로들.
|
|
65
|
+
*/
|
|
66
|
+
export function patchEnvFiles(root, app) {
|
|
67
|
+
const secretEnv = sessionSecretEnvFor(app);
|
|
68
|
+
const patched = [];
|
|
69
|
+
for (const rel of ['.env', '.env.example']) {
|
|
70
|
+
const abs = join(root, rel);
|
|
71
|
+
if (!existsSync(abs))
|
|
72
|
+
continue;
|
|
73
|
+
const existing = readFileSync(abs, 'utf8');
|
|
74
|
+
// 라인 시작에서 `KEY=` 를 찾는다(주석·부분 일치 회피).
|
|
75
|
+
if (new RegExp(`^${secretEnv}=`, 'm').test(existing))
|
|
76
|
+
continue;
|
|
77
|
+
const block = `\n# ${app} 앱 세션 secret (32자 이상 · 앱별 세션 완전 분리 · 운영은 반드시 교체).\n` +
|
|
78
|
+
`${secretEnv}=${envSecretPlaceholderFor(app)}\n`;
|
|
79
|
+
const sep = existing.endsWith('\n') ? '' : '\n';
|
|
80
|
+
writeFileSync(abs, existing + sep + block, 'utf8');
|
|
81
|
+
patched.push(rel);
|
|
82
|
+
}
|
|
83
|
+
return patched;
|
|
84
|
+
}
|
|
85
|
+
/** 결정 155: Login 페이지의 회원가입 링크 — 공개 가입 앱에만 넣는다(시큐어 앱은 뺀다).
|
|
86
|
+
* 템플릿의 `{{SIGNUP_LINK}}` 자리(8칸 들여쓰기)에 in-place 치환되므로 첫 줄은 들여쓰기 없이. */
|
|
87
|
+
function signupLinkMarkup(app) {
|
|
88
|
+
return (`<p class="mt-4 text-center text-sm text-muted-foreground">\n` +
|
|
89
|
+
` 계정이 없으신가요?\n` +
|
|
90
|
+
` <Link href="${urlPrefixFor(app)}/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>\n` +
|
|
91
|
+
` </p>`);
|
|
92
|
+
}
|
|
93
|
+
/** 템플릿 파일을 읽어 토큰을 치환한다. {{APP_NAME}}·{{URL_PREFIX}}·{{SESSION_SECRET_ENV}}·
|
|
94
|
+
* {{SIGNUP_LINK}} 는 Vue 의 {{ }} 보간과 겹치지 않는 고정 리터럴이라 단순 replaceAll 로
|
|
95
|
+
* 안전하다(결정 141·155). SIGNUP_LINK 는 공개 가입 앱에만 링크를, 시큐어 앱엔 빈 값. */
|
|
96
|
+
function renderTemplate(name, app, includePublic) {
|
|
49
97
|
const raw = readFileSync(join(TEMPLATE_DIR, name), 'utf8');
|
|
50
98
|
return raw
|
|
51
99
|
.replaceAll('{{APP_NAME}}', app)
|
|
52
100
|
.replaceAll('{{URL_PREFIX}}', urlPrefixFor(app))
|
|
53
|
-
.replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app))
|
|
101
|
+
.replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app))
|
|
102
|
+
.replaceAll('{{SIGNUP_LINK}}', includePublic ? signupLinkMarkup(app) : '');
|
|
54
103
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
{ tpl: 'user.schema.ts.tpl', out: () => 'domain/schema/users.ts' },
|
|
58
|
-
{ tpl: 'user.model.ts.tpl', out: () => 'domain/models/User.ts' },
|
|
59
|
-
{ tpl: 'auth.wiring.ts.tpl', out: (a) => `apps/${a}/auth.ts` },
|
|
60
|
-
{ tpl: 'session.controller.ts.tpl', out: (a) => `apps/${a}/controllers/session.ts` },
|
|
61
|
-
{ tpl: 'registration.controller.ts.tpl', out: (a) => `apps/${a}/controllers/registration.ts` },
|
|
62
|
-
{ tpl: 'dashboard.controller.ts.tpl', out: (a) => `apps/${a}/controllers/dashboard.ts` },
|
|
63
|
-
// 결정 32·46: Vue 페이지 경로 세그먼트는 PascalCase(Route 이름) — 'Auth/'.
|
|
64
|
-
// examples/blog 정본과 page-filename doctor 규칙에 정합(소문자 'auth/' 는
|
|
65
|
-
// doctor 가 error 로 잡던 스캐폴드 표류였다 · W10 실측).
|
|
66
|
-
{ tpl: 'Login.vue.tpl', out: (a) => `apps/${a}/pages/Auth/Login.vue` },
|
|
67
|
-
{ tpl: 'Signup.vue.tpl', out: (a) => `apps/${a}/pages/Auth/Signup.vue` },
|
|
68
|
-
{ tpl: 'Dashboard.vue.tpl', out: (a) => `apps/${a}/pages/Dashboard.vue` },
|
|
69
|
-
// 결정 59: 세션·인증 배선은 app.config.ts — 표준 부팅(gaon dev/serve = wireGaon)이
|
|
70
|
-
// 소비한다. 과거의 수동 부팅 스캐폴드(app.ts·server.ts)는 두 번째 부팅 경로를
|
|
71
|
-
// 만들어 One Way 를 깨고, 표준 경로에는 auth 배선이 빠져 currentUser 가 영구
|
|
72
|
-
// null 이 되는 파손을 남겼다 — 제거했다.
|
|
73
|
-
{ tpl: 'app.config.ts.tpl', out: (a) => `apps/${a}/app.config.ts` },
|
|
74
|
-
];
|
|
75
|
-
/** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다. */
|
|
104
|
+
/** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
|
|
105
|
+
* 결정 155: 공개 가입 여부(includePublic)로 registration·Signup·dashboard 변형을 가른다. */
|
|
76
106
|
export function authScaffoldFiles(opts = {}) {
|
|
77
107
|
const app = opts.app ?? 'web';
|
|
78
|
-
|
|
108
|
+
const includePublic = includesPublicRegistration(opts);
|
|
109
|
+
const specs = [
|
|
110
|
+
{ tpl: 'user.schema.ts.tpl', out: 'domain/schema/users.ts' },
|
|
111
|
+
{ tpl: 'user.model.ts.tpl', out: 'domain/models/User.ts' },
|
|
112
|
+
{ tpl: 'auth.wiring.ts.tpl', out: `apps/${app}/auth.ts` },
|
|
113
|
+
{ tpl: 'session.controller.ts.tpl', out: `apps/${app}/controllers/session.ts` },
|
|
114
|
+
// 결정 155: 시큐어(비-web · 비-public) 앱은 역할 게이트 대시보드, 공개 앱은 requireAuth 대시보드.
|
|
115
|
+
{
|
|
116
|
+
tpl: includePublic ? 'dashboard.controller.ts.tpl' : 'dashboard.secure.controller.ts.tpl',
|
|
117
|
+
out: `apps/${app}/controllers/dashboard.ts`,
|
|
118
|
+
},
|
|
119
|
+
// 결정 32·46: Vue 페이지 경로 세그먼트는 PascalCase(Route 이름) — 'Auth/'.
|
|
120
|
+
{ tpl: 'Login.vue.tpl', out: `apps/${app}/pages/Auth/Login.vue` },
|
|
121
|
+
{ tpl: 'Dashboard.vue.tpl', out: `apps/${app}/pages/Dashboard.vue` },
|
|
122
|
+
// 결정 59: 세션·인증 배선은 app.config.ts — 표준 부팅(gaon dev/serve = wireGaon)이 소비한다.
|
|
123
|
+
{ tpl: 'app.config.ts.tpl', out: `apps/${app}/app.config.ts` },
|
|
124
|
+
];
|
|
125
|
+
// 결정 155: 공개 가입 앱만 회원가입 컨트롤러·페이지를 깐다. 시큐어 앱(admin 등)은 빼서
|
|
126
|
+
// "관리 앱에 공개 가입 + 로그인 고객 200" 위험 기본을 구조적으로 차단한다.
|
|
127
|
+
if (includePublic) {
|
|
128
|
+
specs.splice(4, 0, { tpl: 'registration.controller.ts.tpl', out: `apps/${app}/controllers/registration.ts` });
|
|
129
|
+
specs.push({ tpl: 'Signup.vue.tpl', out: `apps/${app}/pages/Auth/Signup.vue` });
|
|
130
|
+
}
|
|
131
|
+
return specs.map(({ tpl, out }) => ({ path: out, contents: renderTemplate(tpl, app, includePublic) }));
|
|
79
132
|
}
|
|
80
133
|
/**
|
|
81
134
|
* 기존 routes.ts 에 세션·회원가입 리소스를 끼워 넣는다. 이미 있으면 null.
|
|
82
135
|
* `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
|
|
83
136
|
*/
|
|
84
|
-
export function patchRoutes(existing) {
|
|
137
|
+
export function patchRoutes(existing, includePublic = true) {
|
|
85
138
|
if (existing.includes("resource('session')"))
|
|
86
139
|
return null;
|
|
87
140
|
const m = existing.match(/routes\(\s*\(\s*\w+\s*\)\s*=>\s*\{/);
|
|
@@ -91,9 +144,10 @@ export function patchRoutes(existing) {
|
|
|
91
144
|
// dashboard 라우트도 함께 배선한다 — 없으면 스캐폴드 Dashboard.vue 의
|
|
92
145
|
// pageProps<'…:dashboard#show'> 가 라우트 맵에서 해상되지 않아 vue-tsc 가
|
|
93
146
|
// 깨진다(결정 60 클린룸 실측 — gaon new 프로젝트에 g auth 를 얹는 경로).
|
|
147
|
+
// 결정 155: 회원가입 리소스는 공개 가입 앱에만(시큐어 앱은 빼서 공개 가입 라우트 자체를 안 깐다).
|
|
94
148
|
const inject = "\n r.get('/dashboard', 'dashboard#show') // 보호 페이지 (gaon g auth)" +
|
|
95
149
|
"\n r.resource('session') // 로그인/로그아웃 (gaon g auth)" +
|
|
96
|
-
"\n r.resource('registration') // 회원가입 (gaon g auth)";
|
|
150
|
+
(includePublic ? "\n r.resource('registration') // 회원가입 (gaon g auth)" : '');
|
|
97
151
|
return existing.slice(0, insertAt) + inject + existing.slice(insertAt);
|
|
98
152
|
}
|
|
99
153
|
/**
|
|
@@ -152,6 +206,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
|
|
|
152
206
|
// 필요한 최소 세트를 먼저 보장한다 — 이미 있으면(gaon g ui-kit 를 먼저 돌린
|
|
153
207
|
// 경우) skip, 없으면 생성. 이 보장이 없으면 스캐폴드 직후 페이지가 컴포넌트를
|
|
154
208
|
// 해상하지 못해 vue-tsc 가 깨진다(gaon new → g auth 단독 경로 · 결정 60 게이트).
|
|
209
|
+
const includePublic = includesPublicRegistration(opts);
|
|
155
210
|
const ui = writeUiKitFiles(root, authUiKitFiles(app));
|
|
156
211
|
created.push(...ui.created);
|
|
157
212
|
skipped.push(...ui.skipped);
|
|
@@ -194,7 +249,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
|
|
|
194
249
|
const routesPath = join(root, 'apps', app, 'routes.ts');
|
|
195
250
|
const routesRel = `apps/${app}/routes.ts`;
|
|
196
251
|
if (existsSync(routesPath)) {
|
|
197
|
-
const patchedContent = patchRoutes(readFileSync(routesPath, 'utf8'));
|
|
252
|
+
const patchedContent = patchRoutes(readFileSync(routesPath, 'utf8'), includePublic);
|
|
198
253
|
if (patchedContent) {
|
|
199
254
|
writeFileSync(routesPath, patchedContent, 'utf8');
|
|
200
255
|
patched.push(routesRel);
|
|
@@ -205,20 +260,33 @@ export function writeAuthScaffold(cwd, opts = {}) {
|
|
|
205
260
|
}
|
|
206
261
|
else {
|
|
207
262
|
mkdirSync(dirname(routesPath), { recursive: true });
|
|
208
|
-
writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app), 'utf8');
|
|
263
|
+
writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app, includePublic), 'utf8');
|
|
209
264
|
created.push(routesRel);
|
|
210
265
|
}
|
|
266
|
+
// 결정 155·142(W3): 앱별 세션 secret 을 .env·.env.example 에 시드한다. web 은 이미
|
|
267
|
+
// 있어 멱등, 비-web 앱은 여기서 키가 심어져 "공개 고정 dev secret 폴백" 표면을 막는다.
|
|
268
|
+
patched.push(...patchEnvFiles(root, app));
|
|
269
|
+
// 결정 155: 시큐어 앱(공개 가입 미생성)은 역할 게이트 대시보드를 깔았다 — role 컬럼을
|
|
270
|
+
// 두라고 안내한다(§7.5.3 · authorize 예시가 실제 역할 규칙이 되도록).
|
|
271
|
+
if (!includePublic) {
|
|
272
|
+
warnings.push(`apps/${app}/ 는 시큐어 스캐폴드입니다(공개 회원가입 미생성 · 결정 155).\n` +
|
|
273
|
+
`→ 관리자는 직접 만들거나 승격하세요(공개 가입 라우트 없음). 공개 가입이 필요하면 --public 로 다시 생성.\n` +
|
|
274
|
+
`→ 역할 인가를 완성하려면 domain/schema/users.ts 에 role 컬럼을 추가하세요:\n` +
|
|
275
|
+
` role: t.string().default('user'),\n` +
|
|
276
|
+
` 그러면 apps/${app}/controllers/dashboard.ts 의 authorize(역할) 게이트가 실제 역할로 동작합니다.`);
|
|
277
|
+
}
|
|
211
278
|
return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort(), warnings };
|
|
212
279
|
}
|
|
213
280
|
/** `gaon g auth` 진입점. 스캐폴드를 쓰고 결과를 사람/JSON 으로 출력한다. */
|
|
214
281
|
export function runGenerateAuthCommand(opts = {}) {
|
|
215
282
|
const cwd = opts.cwd ?? process.cwd();
|
|
216
283
|
const app = opts.app ?? 'web';
|
|
217
|
-
const result = writeAuthScaffold(cwd, { app });
|
|
284
|
+
const result = writeAuthScaffold(cwd, { app, public: opts.public });
|
|
218
285
|
if (opts.json) {
|
|
219
|
-
process.stdout.write(JSON.stringify({ command: 'g auth', app, ...result }, null, 2) + '\n');
|
|
286
|
+
process.stdout.write(JSON.stringify({ command: 'g auth', app, public: opts.public ?? app === 'web', ...result }, null, 2) + '\n');
|
|
220
287
|
return 0;
|
|
221
288
|
}
|
|
289
|
+
const includePublic = includesPublicRegistration({ app, public: opts.public });
|
|
222
290
|
const lines = [''];
|
|
223
291
|
lines.push(` gaon g auth — 인증 스캐폴드 (${app} 앱)`);
|
|
224
292
|
lines.push('');
|
|
@@ -237,7 +305,17 @@ export function runGenerateAuthCommand(opts = {}) {
|
|
|
237
305
|
lines.push(` 1) .env 에 REDIS_URL·${secretEnv}(32자 이상)·COOKIE_SECRET 을 설정한다.`);
|
|
238
306
|
lines.push(' 2) gaon db diff && gaon db migrate 로 users 테이블을 만든다.');
|
|
239
307
|
lines.push(' 3) gaon dev 로 실행한다 (.gaon 타입 브리지 생성 + 세션·인증 자동 배선).');
|
|
240
|
-
|
|
308
|
+
// 결정 155(W4): 안내문을 실제 산출물과 정합시킨다. 시큐어 스캐폴드(비-web · 비-public)는
|
|
309
|
+
// 공개 가입을 안 깔았으므로 없는 /registration/new 경로를 광고하지 않는다 — 대신 역할
|
|
310
|
+
// 부여 경로(seed/DB)와 필요 시 --public 을 안내한다(생성물 ≠ 안내문 자기모순 제거).
|
|
311
|
+
if (includePublic) {
|
|
312
|
+
lines.push(` → ${prefix}/registration/new 회원가입 · ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
lines.push(` → ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
|
|
316
|
+
lines.push(` → 공개 회원가입 없음(시큐어 스캐폴드). 관리자 계정은 seed/DB 로 만들거나 승격하세요.`);
|
|
317
|
+
lines.push(` 공개 가입이 필요하면: gaon g auth --app ${app} --public`);
|
|
318
|
+
}
|
|
241
319
|
lines.push('');
|
|
242
320
|
process.stdout.write(lines.join('\n') + '\n');
|
|
243
321
|
return 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { runCheckCommand, type CheckCommandOptions, type CheckStep, type CheckSt
|
|
|
6
6
|
export { runGenCommand, regenerateProjectGaon, type GenCommandOptions, type GenResult, } from "./commands/gen.js";
|
|
7
7
|
export { runBuildCommand, type BuildCommandOptions, type BuildCommandResult, } from "./commands/build.js";
|
|
8
8
|
export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, type BuildAppOptions, type DistVerifyResult, } from "./dev/build.js";
|
|
9
|
+
export { generateMessagesDts } from "./messages-gen.js";
|
|
9
10
|
export { runNewCommand, type NewCommandOptions, type NewCommandResult } from "./commands/new.js";
|
|
10
11
|
export { runConsoleCommand, type ConsoleCommandOptions } from "./commands/console.js";
|
|
11
12
|
export { runTestCommand, type TestCommandOptions, type TestScope } from "./commands/test.js";
|
package/dist/index.js
CHANGED
|
@@ -35,6 +35,7 @@ export { runCheckCommand, } from "./commands/check.js";
|
|
|
35
35
|
export { runGenCommand, regenerateProjectGaon, } from "./commands/gen.js";
|
|
36
36
|
export { runBuildCommand, } from "./commands/build.js";
|
|
37
37
|
export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, } from "./dev/build.js";
|
|
38
|
+
export { generateMessagesDts } from "./messages-gen.js";
|
|
38
39
|
export { runNewCommand } from "./commands/new.js";
|
|
39
40
|
export { runConsoleCommand } from "./commands/console.js";
|
|
40
41
|
export { runTestCommand } from "./commands/test.js";
|
|
@@ -103,7 +104,7 @@ function renderHelp(version = VERSION) {
|
|
|
103
104
|
" gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
|
|
104
105
|
" gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
|
|
105
106
|
" gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
|
|
106
|
-
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --
|
|
107
|
+
" gaon check typecheck · vue-tsc · build · doctor 통합 검사 (--only <step> · --no-doctor)",
|
|
107
108
|
" gaon gen .gaon 타입 브리지 + api() 런타임 매니페스트만 재생성 (서버·검사 없이 · build 전제 · --json)",
|
|
108
109
|
" gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
|
|
109
110
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
@@ -224,8 +225,8 @@ export function runCli(argv, opts = {}) {
|
|
|
224
225
|
return;
|
|
225
226
|
}
|
|
226
227
|
// `gaon check` — typecheck · vue-tsc · build (·doctor) 통합 검사(M9-G).
|
|
227
|
-
// package.json 스크립트 관례 재사용. --only <step> 로 단일
|
|
228
|
-
//
|
|
228
|
+
// package.json 스크립트 관례 재사용. --only <step> 로 단일 단계. 결정 157: doctor 는
|
|
229
|
+
// 기본 포함(규칙 5 등이 check 만 도는 CI 에서 새지 않게) · --no-doctor 로만 뺀다.
|
|
229
230
|
if (argv[0] === "check") {
|
|
230
231
|
const knownSteps = ["typecheck", "vue-tsc", "build", "doctor"];
|
|
231
232
|
const onlyIdx = argv.indexOf("--only");
|
|
@@ -236,7 +237,7 @@ export function runCli(argv, opts = {}) {
|
|
|
236
237
|
void runCheckCommand({
|
|
237
238
|
json: argv.includes("--json"),
|
|
238
239
|
only,
|
|
239
|
-
|
|
240
|
+
noDoctor: argv.includes("--no-doctor"),
|
|
240
241
|
})
|
|
241
242
|
.then((code) => {
|
|
242
243
|
process.exitCode = code;
|
|
@@ -395,7 +396,8 @@ export function runCli(argv, opts = {}) {
|
|
|
395
396
|
if (argv[1] === "auth") {
|
|
396
397
|
const appIdx = argv.indexOf("--app");
|
|
397
398
|
const app = appIdx >= 0 ? argv[appIdx + 1] : undefined;
|
|
398
|
-
|
|
399
|
+
// 결정 155: --public 은 비-web 앱도 공개 회원가입을 opt-in(공개 비-web 앱 탈출구).
|
|
400
|
+
const code = runGenerateAuthCommand({ app, json: argv.includes("--json"), public: argv.includes("--public") });
|
|
399
401
|
process.exitCode = code;
|
|
400
402
|
return;
|
|
401
403
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @gaonjs/cli · .gaon/messages.d.ts 생성기 (결정 158 · 13차 W2)
|
|
2
|
+
//
|
|
3
|
+
// tables.d.ts·routes.d.ts 와 같은 .gaon 파이프라인의 세 번째 축(메시지). locales/
|
|
4
|
+
// 카탈로그의 키를 유니온 타입으로 물성화해, t('key') 의 존재하지 않는 키를 컴파일
|
|
5
|
+
// 타임에 잡는다(현재는 GaonMessages 가 비어 있어 키가 string 으로 열림). 생성 파일은
|
|
6
|
+
// 타입만 담는다(규칙 3). @gaonjs/i18n 의 공개 API(loadLocales·renderMessagesDts)만 쓴다.
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
|
|
10
|
+
/**
|
|
11
|
+
* locales/ 카탈로그에서 .gaon/messages.d.ts 를 생성한다. 카탈로그가 없거나 비어
|
|
12
|
+
* 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
|
|
13
|
+
* 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
|
|
14
|
+
*/
|
|
15
|
+
export function generateMessagesDts(localesDir, out) {
|
|
16
|
+
if (!existsSync(localesDir))
|
|
17
|
+
return false;
|
|
18
|
+
const resources = loadLocales(localesDir);
|
|
19
|
+
if (Object.keys(resources).length === 0)
|
|
20
|
+
return false;
|
|
21
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
22
|
+
writeFileSync(out, renderMessagesDts(resources), 'utf8');
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
@@ -45,10 +45,7 @@ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
|
|
|
45
45
|
</FormField>
|
|
46
46
|
<Button type="submit" class="w-full" :disabled="form.processing">로그인</Button>
|
|
47
47
|
</Form>
|
|
48
|
-
|
|
49
|
-
계정이 없으신가요?
|
|
50
|
-
<Link href="{{URL_PREFIX}}/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>
|
|
51
|
-
</p>
|
|
48
|
+
{{SIGNUP_LINK}}
|
|
52
49
|
</CardContent>
|
|
53
50
|
</Card>
|
|
54
51
|
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// 보호 라우트 예시(관리 앱) — gaon g auth --app {{APP_NAME}}. 로그인 + 역할로 지킨다(§7 · 결정 145·155).
|
|
2
|
+
import { controller } from 'gaonjs/web'
|
|
3
|
+
|
|
4
|
+
export default controller({
|
|
5
|
+
// GET {{URL_PREFIX}}/dashboard — 로그인만으론 부족한 관리 화면. 역할(role)로 인가한다.
|
|
6
|
+
async show() {
|
|
7
|
+
const user = this.requireAuth() // 인증(401): 로그인 여부
|
|
8
|
+
// 인가(403): 로그인한 일반 사용자는 막는다. domain/schema/users.ts 에 role 컬럼을 두고
|
|
9
|
+
// (예: role: t.string().default('user')) 아래를 실제 역할 규칙으로 바꾸세요.
|
|
10
|
+
// 지금은 role !== 'admin' 이면 403 — 관리 앱에 로그인 고객이 들어오는 위험 기본을 막는다.
|
|
11
|
+
this.authorize((user as { role?: string }).role === 'admin')
|
|
12
|
+
return this.render('Dashboard', {})
|
|
13
|
+
},
|
|
14
|
+
})
|
|
@@ -18,6 +18,12 @@ STORAGE_BUCKET={{PROJECT_NAME}}
|
|
|
18
18
|
STORAGE_ACCESS_KEY={{PROJECT_NAME}}
|
|
19
19
|
STORAGE_SECRET_KEY={{PROJECT_NAME}}_secret
|
|
20
20
|
|
|
21
|
+
# 메일(§7 · M8) — docker-compose.yaml 의 mailpit 서비스와 정합(dev = MailPit sink · UI :8025).
|
|
22
|
+
# 운영은 실 SMTP 호스트·자격증명·SMTP_SECURE=true 로 교체한다.
|
|
23
|
+
SMTP_HOST=127.0.0.1
|
|
24
|
+
SMTP_PORT=1025
|
|
25
|
+
MAIL_FROM=no-reply@{{PROJECT_NAME}}.test
|
|
26
|
+
|
|
21
27
|
# 세션 · 쿠키 서명 비밀 (32자 이상, 운영은 반드시 교체).
|
|
22
28
|
SESSION_SECRET=change-me-to-a-32-char-random-secret!!
|
|
23
29
|
COOKIE_SECRET=change-me-too-32-char-random-secret!!
|
|
@@ -27,6 +27,8 @@ v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구
|
|
|
27
27
|
| 잡 · 이벤트 · 리스너 · 아웃박스 · 스케줄 | `agents/async.md` |
|
|
28
28
|
| 채널 · 프레즌스 · 허브 | `agents/realtime.md` |
|
|
29
29
|
| 파일 스토리지 (`Storage.put/url` · s3Disk · presigned · CSP 자동 배선) | `agents/storage.md` |
|
|
30
|
+
| 다국어 (`t()` · 카탈로그 · 요청별 로케일 · `this.setLocale` · 메시지 키 타입) | `agents/i18n.md` |
|
|
31
|
+
| 메일 (`mail()` · `deliver(data, { locale })` · MailPit · 발송 경로) | `agents/mail.md` |
|
|
30
32
|
| 테스트 작성·실행 (실 인프라 · `expectJobProcessed`) | `agents/testing.md` |
|
|
31
33
|
| 보안 기본값 · 탈출구(v-html · raw SQL) 사용 | `agents/security.md` |
|
|
32
34
|
| 페이로드 봉인 (`@gaonjs/seal` · wire/문서/WS 암호화 · 선택 플러그인) | `agents/seal.md` |
|
|
@@ -191,7 +193,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
191
193
|
작업마다 실행한다:
|
|
192
194
|
|
|
193
195
|
```bash
|
|
194
|
-
gaon check # .gaon 재생성 → typecheck + vue-tsc + build
|
|
196
|
+
gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
|
|
195
197
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
196
198
|
gaon doctor # 정적 검사 24종 (§2.2)
|
|
197
199
|
```
|
|
@@ -84,7 +84,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
84
84
|
## 3. 개발 검증 루프 (작업마다 실행)
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
gaon check # .gaon 재생성
|
|
87
|
+
gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
|
|
88
88
|
gaon doctor # 정적 검사 24종 (상세 AGENTS §2.2)
|
|
89
89
|
npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
|
|
90
90
|
```
|
|
@@ -253,6 +253,38 @@ export default schedule((s) => {
|
|
|
253
253
|
프로세스 3종(serve·work·hub) 중 하나. SIGTERM/SIGINT 에 graceful
|
|
254
254
|
drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤 종료한다.
|
|
255
255
|
|
|
256
|
+
### 7. 분산 락 (`lock()`) (결정 147)
|
|
257
|
+
|
|
258
|
+
**동시 실행을 막아야 하면 `lock(key, fn)`** — 같은 `key` 에 대해 전
|
|
259
|
+
인스턴스를 통틀어 동시 1개의 `fn` 만 임계구역에 들인다. **로컬 뮤텍스는
|
|
260
|
+
반정본이다** — 멀티 인스턴스(워커 여러 대·serve 여러 대)에서는 프로세스마다
|
|
261
|
+
따로 놀아 무의미하다(결정 88 ①). 그래서 백엔드는 Redis 다: 설정에 `redis`
|
|
262
|
+
가 있으면 `gaon serve` 가 분산 락을 자동 배선한다. **`redis` 미설정 상태로
|
|
263
|
+
`lock()` 을 부르면 로컬 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께
|
|
264
|
+
throw** 한다.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { lock } from 'gaonjs/async'
|
|
268
|
+
|
|
269
|
+
// 일 1회 집계가 인스턴스 여러 대에서 중복 실행되지 않게.
|
|
270
|
+
await lock('report:daily', async () => {
|
|
271
|
+
await buildDailyReport()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// 이미 다른 인스턴스가 돌고 있으면 스킵(대기하지 않음).
|
|
275
|
+
await lock('sync:external', syncNow, { onBusy: 'skip' })
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- **기본 정책은 대기**(`onBusy:'wait'`) — 홀더가 놓을 때까지 기다렸다가
|
|
279
|
+
들어간다. `acquireTimeoutMs`(기본 10s)를 넘기면 `LockTimeoutError`.
|
|
280
|
+
`onBusy:'skip'` 이면 즉시 포기하고 `fn` 을 실행하지 않는다(반환값
|
|
281
|
+
`undefined`).
|
|
282
|
+
- **TTL 로 데드락을 막는다**(`ttlMs` 기본 30s) — 홀더가 크래시해도 TTL
|
|
283
|
+
만료 뒤 자동 해제된다. `fn` 이 TTL 보다 오래 돌면 워치독이 자동으로
|
|
284
|
+
락을 연장하므로 임계구역을 뺏기지 않는다.
|
|
285
|
+
- **키는 호출자가 정한다** — 락 범위(자원 단위)를 `key` 로 표현한다.
|
|
286
|
+
프레임웍이 로케일 등 변이 축을 자동으로 섞지 않는다.
|
|
287
|
+
|
|
256
288
|
## 정본 예시
|
|
257
289
|
|
|
258
290
|
회원 가입 → 환영 메일 비동기 발송 세로 조각 (§7 원문 예시):
|
|
@@ -298,11 +330,16 @@ async create() {
|
|
|
298
330
|
발행은 `afterCommit()` 또는 아웃박스로.
|
|
299
331
|
- **테스트에서 NATS 목업 금지** (§9) — 실 JetStream 에 접속한다
|
|
300
332
|
(`agents/testing.md`).
|
|
333
|
+
- **동시 실행 방지에 로컬 뮤텍스·플래그 금지** (결정 147) — `let running = false`
|
|
334
|
+
같은 프로세스 로컬 가드는 멀티 인스턴스에서 안 먹는다. `lock(key, fn)` 을
|
|
335
|
+
쓴다. `redis` 미설정이면 `lock()` 이 수리 안내로 throw 하니 조용한 파손이
|
|
336
|
+
없다.
|
|
301
337
|
|
|
302
338
|
## 관련 결정 번호
|
|
303
339
|
|
|
304
340
|
| 결정 | 내용 |
|
|
305
341
|
|---|---|
|
|
342
|
+
| 결정 147 | 분산 락 `lock(key, fn)` (Redis 백엔드 · 로컬 뮤텍스 반정본 · TTL 데드락 방지 · 워치독) |
|
|
306
343
|
| 결정 102 | 비동기 배치 One Way 판단표 (동기 인라인 vs 잡/이벤트/스케줄 · 서두 표) |
|
|
307
344
|
| 결정 103 | doctor `async-offload` 검사 (컨트롤러 인라인 메일·이미지·외부 HTTP 경고) |
|
|
308
345
|
| 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 모두 정합) |
|
|
@@ -300,8 +300,10 @@ methods: {
|
|
|
300
300
|
**앞**에서 끝낸다. `withCount` 는 예외로 `include` 와 같은 자리에 실린다.
|
|
301
301
|
- `groupBy` 이후는 `GroupChain` — 결과가 그룹 행이라 `first`/`all` 대신
|
|
302
302
|
집계 함수가 종단이고, 레코드가 아니라 `include`·`select` 도 없다.
|
|
303
|
-
- `join`/`leftJoin` 이후는 `JoinChain` — 반환은 자기 Rec 이라
|
|
304
|
-
|
|
303
|
+
- `join`/`leftJoin` 이후는 `JoinChain` — 반환은 자기 Rec 이라 `include`·집계 그룹은
|
|
304
|
+
없지만 `where`/`whereAny`/`orWhere`/`orderBy`/`distinct`/`limit`/`offset`·스칼라 집계·
|
|
305
|
+
`select`(자기 컬럼)·`pluck`·`first`/`all`/**`paginate`** 는 이어진다. 그래서 **텍스트
|
|
306
|
+
검색(whereAny)+관계 필터(join)+페이지네이션을 한 체인으로** 조립할 수 있다(읽기 조합).
|
|
305
307
|
- `select()` 이후엔 `include` 도 없다 (부분 행에 관계를 붙이지 않는다).
|
|
306
308
|
- 스코프(§8)는 `Chain` 의 어느 지점에서든 재호출 가능
|
|
307
309
|
(`Post.where(...).published()` 도 됨).
|
|
@@ -490,6 +492,12 @@ export const Post = model(posts, {
|
|
|
490
492
|
(필수 강제 · 스키마 밖 키 제거 = 대량 할당 차단)까지 한다. 컨트롤러 쪽
|
|
491
493
|
사용법(폼 모양 판단·라우트 파라미터 병합)은 `agents/web.md` §3 이 정본이다.
|
|
492
494
|
|
|
495
|
+
**컬럼 제약(`.max(n)`·enum)도 쓰기 전 서버측에서 검증한다**(결정 153) —
|
|
496
|
+
`t.string().max(200)` 을 넘긴 값이 200자를 넘거나 enum 허용값 밖이면 DB 에
|
|
497
|
+
닿기 전에 `ValidationError` → **422(폼 에러)** 로 마감한다. 초과 입력이 DB
|
|
498
|
+
제약 위반(varchar 길이·CHECK)으로 **raw 500** 에 새지 않는다. 클라이언트
|
|
499
|
+
`.max`(HTML)만 믿지 말고 서버 폼 검증이 정본 방어층이다.
|
|
500
|
+
|
|
493
501
|
**일부 컬럼만 검증해서 받으려면 `pick()`** — 지정한 컬럼만 담은 **새 폼**을
|
|
494
502
|
돌려준다(원 폼 불변). 컬럼 타입·검증·기본값 정보가 그대로 따라오므로,
|
|
495
503
|
"검증되는 부분 폼"이 필요할 때 애드혹 `{ _row: {} as T }`(검증 없음) 대신 쓴다.
|
|
@@ -514,6 +522,29 @@ async create() {
|
|
|
514
522
|
동적 default(`now()`·`gen_random_uuid()`·bigserial)는 채우지 않고 DB 가 채운다.
|
|
515
523
|
- `omit`·`extend`·`merge` 는 **없다** — 폼 변형은 `pick()` 하나가 The One Way.
|
|
516
524
|
|
|
525
|
+
### 8.2 캐시 — 명시 TTL 만 (`cache` · `.withCache` · 결정 148)
|
|
526
|
+
|
|
527
|
+
비싼 조회·계산 결과를 **명시 TTL** 로 캐시한다. `gaonjs/data` 에서 온다.
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
import { cache } from 'gaonjs/data'
|
|
531
|
+
|
|
532
|
+
// 키가 있으면 캐시 값, 없으면 fn 을 돌려 60초 캐시.
|
|
533
|
+
const stats = await cache.remember('stats:home', 60, () => computeHomeStats())
|
|
534
|
+
await cache.forget('stats:home') // 명시 무효화
|
|
535
|
+
|
|
536
|
+
// 쿼리 종단 헬퍼 — all()/first() 결과를 캐시(키는 쿼리에서 자동 도출).
|
|
537
|
+
const top = await Post.published().latest().limit(5).withCache(30).all()
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
- **자동 무효화는 없다** — 쓰기(`create`/`update`/`delete`)가 캐시를
|
|
541
|
+
**지우지 않는다**. 정합이 복잡하고 틀리면 조용한 stale 을 낳기 때문이다.
|
|
542
|
+
신선도가 중요하면 **짧은 TTL** 을 쓰거나 `cache.forget(key)` 로 명시
|
|
543
|
+
무효화한다. `.withCache` 결과는 TTL 만료로만 갱신된다(자동 퍼지 없음).
|
|
544
|
+
- **키는 호출자가 정한다** — 로케일·사용자 등 변이 축은 **키에 직접 넣는다**
|
|
545
|
+
(`` `page:${locale}` ``). 프레임웍이 변이 축을 자동으로 섞지 않는다.
|
|
546
|
+
- 백엔드는 Redis 가 기본(설정에 `redis` 있으면 자동), 메모리는 dev/테스트/폴백.
|
|
547
|
+
|
|
517
548
|
### 9. 서비스 (`service()`) — 트랜잭션 작업 흐름 (정본 §5.3 · `packages/data/src/service.ts`)
|
|
518
549
|
|
|
519
550
|
로직 배치의 One Way 규칙은 루트 `AGENTS.md` 판단표가 정본이다 (정본 §5.3):
|
|
@@ -569,6 +600,13 @@ gaon db seed # domain/seed.ts 실행 (전 커넥션)
|
|
|
569
600
|
diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션을 순회**한다
|
|
570
601
|
(결정 139). `gaon db diff`(내부 `_gaon_*` 테이블은 계획에서 제외 · 결정 138).
|
|
571
602
|
|
|
603
|
+
> **db 명령은 프로젝트 로컬로 실행하라(`npx gaon ...` · 결정 156).** `gaon db seed` 는
|
|
604
|
+
> 모델 레이어를 거치므로, 전역 설치 CLI 와 프로젝트가 `@gaonjs/data` 를 각각 로드하면
|
|
605
|
+
> 커넥션 레지스트리가 갈려 "main 미등록" 으로 죽을 수 있다(dual package hazard). 프레임웍이
|
|
606
|
+
> 프로젝트 인스턴스에 자동 재등록해 대부분 자동 복구하지만, 확실히 하려면 프로젝트 로컬
|
|
607
|
+
> 실행(`npx gaon` · package.json 스크립트)이 정본이다. migrate/diff/status 는 모델을 안 거쳐
|
|
608
|
+
> 무관하다.
|
|
609
|
+
|
|
572
610
|
**기본은 스키마 우선이다.** `domain/schema/*.ts` 를 고치고 `gaon db migrate`
|
|
573
611
|
하면 diff 가 차이를 계산해 반영한다. 여기에 **손작성 마이그레이션 파일**이
|
|
574
612
|
1급으로 합쳐진다(결정 39 · 합성형): `migrate` 는 ① `db/migrations/*.ts` 를
|
|
@@ -626,6 +664,29 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
626
664
|
미리 보여주고, migrate 는 건너뛴 테이블을 크게 알린다(조용한 무시 방지).
|
|
627
665
|
- 마이그레이션은 커넥션별로 돈다(§7 · `--db <키>`).
|
|
628
666
|
|
|
667
|
+
### 10.1 시드 — `domain/seed.ts` · `seed()` (§7)
|
|
668
|
+
|
|
669
|
+
개발·데모용 초기 데이터를 `domain/seed.ts` 한 파일에 선언하고 `gaon db seed` 로
|
|
670
|
+
실행한다. 파일이 존재하면 등록이고, **default export** 가 시드 정의여야 한다.
|
|
671
|
+
|
|
672
|
+
- **시그니처**: `seed(fn: () => Promise<void> | void): SeedDef` — `gaonjs/data`
|
|
673
|
+
에서 import 한다. 본문(`fn`)은 **모델을 그대로** 쓴다 — 모델이 커넥션을 자동
|
|
674
|
+
바인딩하므로(§4.5·§7) 시드는 커넥션을 몰라도 된다.
|
|
675
|
+
- **멱등하게 짠다** — 시드는 재적재에 자주 쓰이므로 여러 번 돌려도 안전해야
|
|
676
|
+
한다(예: `upsert`/존재 확인 후 생성).
|
|
677
|
+
- `gaon db seed` 는 모델 레이어를 거치므로 **프로젝트 로컬로 실행**하는 것이
|
|
678
|
+
정본이다(`npx gaon db seed` · 결정 156 · 위 dual package hazard 노트).
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
// domain/seed.ts
|
|
682
|
+
import { seed } from 'gaonjs/data'
|
|
683
|
+
import { User } from './models/User'
|
|
684
|
+
|
|
685
|
+
export default seed(async () => {
|
|
686
|
+
await User.create({ email: 'admin@example.com', name: 'Admin' })
|
|
687
|
+
})
|
|
688
|
+
```
|
|
689
|
+
|
|
629
690
|
## 정본 예시
|
|
630
691
|
|
|
631
692
|
```ts
|
|
@@ -751,6 +812,11 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
751
812
|
안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
|
|
752
813
|
다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
|
|
753
814
|
그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
|
|
815
|
+
- **캐시를 "쓰면 자동으로 지워진다"고 기대하면 함정** (결정 148) — `cache`·`.withCache`
|
|
816
|
+
는 **자동 무효화가 없다**. `create` 후에도 같은 `cache.remember`/`.withCache` 키는 TTL
|
|
817
|
+
만료 전까지 stale 을 준다. 신선도가 중요하면 짧은 TTL 이나 `cache.forget(key)`. 자동
|
|
818
|
+
퍼지를 흉내 내려고 쓰기마다 forget 을 흩뿌리지 말 것(정합 복잡·조용한 stale 위험이 기각
|
|
819
|
+
사유였다).
|
|
754
820
|
|
|
755
821
|
## 관련 결정 번호
|
|
756
822
|
|
|
@@ -772,4 +838,6 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
772
838
|
| 결정 115 | 원자 프리미티브 increment·decrement·touch·toggle(Rec) + incrementAll·decrementAll(Chain) · read-modify-write 금지(§4) |
|
|
773
839
|
| 결정 118 | `whereAny(cols, op, val)` — 다중 컬럼 동일 조건 OR 를 괄호로 묶어 AND 안전 결합(§8·정본 예시·함정) · 범용 그룹핑(whereGroup) 은 기각(복합 논리는 Kysely 탈출구) |
|
|
774
840
|
| 결정 119 | `paginate(page, perPage)` — 체인 종단 `{rows,total,page,pageCount,perPage}` · 클램프·개수 number 내장 · UI 킷 Pagination 정합 · 손 조립(쿼리 2회·count 캐스팅·페이지 수학)은 반정본 · GroupChain 미탑재(행 목록 전용) |
|
|
841
|
+
| 결정 148 | 캐시 헬퍼 `cache.remember`/`forget`·쿼리 `.withCache(ttl)` — 명시 TTL 만 · **자동 무효화 없음**(쓰기 자동 퍼지 기각 · 조용한 stale 방지) · Redis 기본·메모리 폴백(§8.2) |
|
|
842
|
+
| 결정 153 | `Model.form` 컬럼 제약(`.max`·enum) 쓰기 전 서버측 검증 → 422(폼 에러) · DB 제약 위반 raw 500 방지(§8.1 · `this.params`) |
|
|
775
843
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -46,8 +46,9 @@ const props = pageProps<'web:posts#index'>()
|
|
|
46
46
|
컴파일 에러 · 결정 117). 레이아웃·컴포넌트에서 라우트를 모른 채 읽을 때 특히 유용하다:
|
|
47
47
|
```vue
|
|
48
48
|
import { useShared } from 'gaonjs/vue'
|
|
49
|
-
const shared = useShared() // { currentUser, csrf, flash } · 반응형
|
|
49
|
+
const shared = useShared() // { currentUser, csrf, flash, ...앱 키 } · 반응형
|
|
50
50
|
// <template> 에서 shared.currentUser?.name · shared.csrf · shared.flash.success
|
|
51
|
+
// 앱이 app.config sharedProps 로 등록한 키(locale·theme 등)도 같은 자리에서 읽힌다(결정 150).
|
|
51
52
|
```
|
|
52
53
|
`pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
|
|
53
54
|
`useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
|
|
@@ -403,6 +404,7 @@ async function runSearch(q: string) {
|
|
|
403
404
|
| 결정 109 | 서버 스키마 검증 실패 → `form.errors.<field>` 자동 반영(303 back + 플래시 · `agents/web.md` §4.1) |
|
|
404
405
|
| 결정 113 | 버튼 모양 링크 = `<Button href>`(Link 로 Button 감싸지 않음 · `<a><button>` 중첩 방지 · doctor link-button-nesting) |
|
|
405
406
|
| 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
|
|
407
|
+
| 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
|
|
406
408
|
| 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
|
|
407
409
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
408
410
|
|