@gaonjs/cli 0.63.0 → 0.64.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/templates/project/agents/async.md.tpl +9 -9
- package/dist/templates/project/agents/data.md.tpl +37 -12
- package/dist/templates/project/agents/frontend.md.tpl +14 -9
- package/dist/templates/project/agents/i18n.md.tpl +22 -6
- package/dist/templates/project/agents/mail.md.tpl +3 -1
- package/dist/templates/project/agents/realtime.md.tpl +54 -31
- package/dist/templates/project/agents/seal.md.tpl +22 -4
- package/dist/templates/project/agents/security.md.tpl +1 -1
- package/dist/templates/project/agents/storage.md.tpl +23 -15
- package/dist/templates/project/agents/testing.md.tpl +5 -5
- package/dist/templates/project/agents/web.md.tpl +20 -8
- package/package.json +13 -13
|
@@ -32,26 +32,26 @@ blocking). 확신이 안 서면 "응답에 이 결과가 필요한가?" 만 묻
|
|
|
32
32
|
|
|
33
33
|
각 배치의 정본 예시 (전체 시그니처·옵션은 아래 §1·§4·§5):
|
|
34
34
|
|
|
35
|
-
```ts
|
|
35
|
+
```ts fragment
|
|
36
36
|
// 잡 — 응답과 분리해 발행 (메일·알림·이미지 처리 등). 컨트롤러/서비스에서:
|
|
37
37
|
await SendWelcomeMail.later(user.id) // domain/jobs/sendWelcomeMail.ts (§1)
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
```ts
|
|
40
|
+
```ts fragment
|
|
41
41
|
// 파생 효과 — 커밋 뒤에만 나가야 하는 발행은 서비스 afterCommit (§4 아웃박스).
|
|
42
42
|
// domain/services/registerUser.ts
|
|
43
43
|
import { service, afterCommit } from 'gaonjs/service' // service·afterCommit 는 같은 서브패스
|
|
44
44
|
import { User } from '../models/User.js'
|
|
45
45
|
import { ResizeAvatar } from '../jobs/resizeAvatar.js'
|
|
46
46
|
|
|
47
|
-
export const RegisterUser = service(async (input:
|
|
47
|
+
export const RegisterUser = service(async (input: { name: string; email: string }) => {
|
|
48
48
|
const user = await User.create(input)
|
|
49
49
|
afterCommit(() => ResizeAvatar.later(user.id)) // 커밋 성공 후에만 발행
|
|
50
50
|
return user
|
|
51
51
|
})
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
```ts
|
|
54
|
+
```ts fragment
|
|
55
55
|
// 주기 작업 — domain/schedule.ts · 대상은 항상 잡 (§5 · 인라인 함수 금지).
|
|
56
56
|
export default schedule((s) => {
|
|
57
57
|
s.cron('0 9 * * 1', SendWeeklyReport) // 매주 월 09:00 · 리더 1인만
|
|
@@ -75,7 +75,7 @@ export const SendWelcomeMail = job(async (userId: bigint) => {
|
|
|
75
75
|
}, { retries: 3 })
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
```ts
|
|
78
|
+
```ts fragment
|
|
79
79
|
await SendWelcomeMail.later(user.id) // 즉시 큐잉(인자 타입 그대로 추론)
|
|
80
80
|
await SendWelcomeMail.in('10m', user.id) // 지연 실행
|
|
81
81
|
await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
|
|
@@ -191,7 +191,7 @@ export default on(OrderPlaced, ({ orderId }) => {
|
|
|
191
191
|
|
|
192
192
|
이벤트 발행:
|
|
193
193
|
|
|
194
|
-
```ts
|
|
194
|
+
```ts fragment
|
|
195
195
|
await OrderPlaced.emit({ orderId: 1n })
|
|
196
196
|
```
|
|
197
197
|
|
|
@@ -431,7 +431,7 @@ REPL 컨텍스트에 실제로 들어 있는 키는 넷이다:
|
|
|
431
431
|
컨텍스트에 자동 노출하지는 않는다(결정 251 — 빈 스텁을 심어 배너로 약속하면 오도라
|
|
432
432
|
아예 심지 않는다). 핸들이 필요하면 **직접 import** 한다(REPL 은 top-level await 허용):
|
|
433
433
|
|
|
434
|
-
```
|
|
434
|
+
```console
|
|
435
435
|
// gaon> — 잡 하나를 큐에 넣는다
|
|
436
436
|
const { closeDueAuctions } = await import('./domain/jobs/closeDueAuctions.ts')
|
|
437
437
|
await closeDueAuctions.later({ auctionId: 42n })
|
|
@@ -454,7 +454,7 @@ await closeDueAuctions.later({ auctionId: 42n })
|
|
|
454
454
|
상태로 `lock()` 을 부르면 로컬 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께
|
|
455
455
|
throw** 한다(개발·테스트는 in-memory 백엔드로 폴백해 단일 프로세스에서 그대로 돈다).
|
|
456
456
|
|
|
457
|
-
```ts
|
|
457
|
+
```ts fragment
|
|
458
458
|
import { lock } from 'gaonjs/async'
|
|
459
459
|
|
|
460
460
|
// 일 1회 집계가 인스턴스 여러 대에서 중복 실행되지 않게.
|
|
@@ -489,7 +489,7 @@ export const SendWelcomeMail = job(async (userId: bigint) => {
|
|
|
489
489
|
}, { retries: 3 })
|
|
490
490
|
```
|
|
491
491
|
|
|
492
|
-
```ts
|
|
492
|
+
```ts controller-action
|
|
493
493
|
// apps/web/controllers/registration.ts — 컨트롤러는 잡 발행만 (직접 발송 금지)
|
|
494
494
|
async create() {
|
|
495
495
|
// 서비스는 모델 폼이 없다 — 컨트롤러가 모델 스키마 폼(`User.form.pick(...)`)으로
|
|
@@ -17,6 +17,7 @@ export const posts = table('posts', {
|
|
|
17
17
|
title: t.string().max(200),
|
|
18
18
|
body: t.text(),
|
|
19
19
|
published: t.boolean().default(false),
|
|
20
|
+
viewCount: t.int().default(0),
|
|
20
21
|
authorId: t.belongsTo('users'), // FK + 관계를 한 줄로
|
|
21
22
|
...t.timestamps(), // createdAt, updatedAt
|
|
22
23
|
})
|
|
@@ -42,6 +43,8 @@ import { table, t, hasMany, hasOne, belongsToMany } from 'gaonjs/data'
|
|
|
42
43
|
export const posts = table('posts', {
|
|
43
44
|
id: t.id(),
|
|
44
45
|
title: t.string().max(200),
|
|
46
|
+
body: t.text(),
|
|
47
|
+
published: t.boolean().default(false),
|
|
45
48
|
authorId: t.belongsTo('users'), // 정방향(FK 보유) = 컬럼
|
|
46
49
|
...t.timestamps(),
|
|
47
50
|
}, {
|
|
@@ -195,6 +198,9 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
195
198
|
표현할 수 없으니 아래 테이블 레벨 `unique` 를 쓴다:
|
|
196
199
|
|
|
197
200
|
```ts
|
|
201
|
+
// domain/schema/orders.ts
|
|
202
|
+
import { table, t } from 'gaonjs/data'
|
|
203
|
+
|
|
198
204
|
export const orders = table('orders', {
|
|
199
205
|
id: t.id(),
|
|
200
206
|
auctionId: t.belongsTo('auctions').unique(), // 1:1 — 경매당 주문 하나
|
|
@@ -212,11 +218,13 @@ export const watches = table(
|
|
|
212
218
|
**테이블 레벨 복합 제약** (E-4 §4.2 · 인덱스 객체 확장 결정 273):
|
|
213
219
|
|
|
214
220
|
```ts
|
|
221
|
+
// domain/schema/logs.ts
|
|
222
|
+
import { table, t } from 'gaonjs/data'
|
|
223
|
+
|
|
215
224
|
export const logs = table('logs', {
|
|
216
225
|
userId: t.belongsTo('users'),
|
|
217
226
|
status: t.enum(['active', 'archived'] as const),
|
|
218
227
|
retries: t.int(),
|
|
219
|
-
createdAt: t.datetime(),
|
|
220
228
|
meta: t.jsonb<Record<string, unknown>>().index(), // 자동 gin
|
|
221
229
|
...t.timestamps(),
|
|
222
230
|
}, {
|
|
@@ -252,6 +260,9 @@ export const logs = table('logs', {
|
|
|
252
260
|
**선언적 파티셔닝** (결정 277 · PostgreSQL · 대용량 로그/이벤트/감사):
|
|
253
261
|
|
|
254
262
|
```ts
|
|
263
|
+
// domain/schema/logs.ts
|
|
264
|
+
import { table, t } from 'gaonjs/data'
|
|
265
|
+
|
|
255
266
|
export const logs = table('logs', {
|
|
256
267
|
id: t.id(),
|
|
257
268
|
createdAt: t.datetime(),
|
|
@@ -427,11 +438,16 @@ export const logs = table('logs', {
|
|
|
427
438
|
|
|
428
439
|
```ts
|
|
429
440
|
// domain/models/Post.ts — 조회수 카운터(원자 · 경쟁 조건 없음)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
441
|
+
import { model } from 'gaonjs/data'
|
|
442
|
+
import { posts } from '../schema/posts.js'
|
|
443
|
+
|
|
444
|
+
export const Post = model(posts, {
|
|
445
|
+
methods: {
|
|
446
|
+
async recordView() {
|
|
447
|
+
return await this.increment('viewCount') // NOT: this.update({ viewCount: this.viewCount + 1 })
|
|
448
|
+
},
|
|
433
449
|
},
|
|
434
|
-
}
|
|
450
|
+
})
|
|
435
451
|
```
|
|
436
452
|
|
|
437
453
|
**체인 상태 전이 주의** (`model.ts`):
|
|
@@ -461,7 +477,7 @@ methods: {
|
|
|
461
477
|
|
|
462
478
|
실 구현 (E-4 (h) 정정 · `model.ts`):
|
|
463
479
|
|
|
464
|
-
```ts
|
|
480
|
+
```ts fragment
|
|
465
481
|
// 조인·집계·CTE 등 복잡 쿼리
|
|
466
482
|
const rows = await Post.query()
|
|
467
483
|
.innerJoin('users', 'users.id', 'posts.authorId')
|
|
@@ -546,9 +562,11 @@ export default defineConfig({
|
|
|
546
562
|
// domain/services/placeOrder.ts — main 커밋 성공 뒤에만 analytics 기록 (파일명 camelCase)
|
|
547
563
|
import { service, afterCommit } from 'gaonjs/service'
|
|
548
564
|
import { getConnection } from 'gaonjs/data'
|
|
565
|
+
import { Order } from '../models/Order.js'
|
|
549
566
|
|
|
550
|
-
export const PlaceOrder = service(async (input: {
|
|
551
|
-
|
|
567
|
+
export const PlaceOrder = service(async (input: { auctionId: bigint; buyerId: bigint; total: number }) => {
|
|
568
|
+
// 컬럼은 §1.1 의 orders 스키마 그대로 — total 은 analytics 쪽 파생값이다.
|
|
569
|
+
const order = await Order.create({ auctionId: input.auctionId, buyerId: input.buyerId }) // main 트랜잭션
|
|
552
570
|
|
|
553
571
|
// 커넥션을 가로지르는 쓰기는 트랜잭션 밖 — 커밋 성공 뒤에만.
|
|
554
572
|
afterCommit(async () => {
|
|
@@ -643,6 +661,9 @@ statics 가 필요하면 **반드시 위 3-제네릭 패턴** — 제네릭 없
|
|
|
643
661
|
|
|
644
662
|
```ts
|
|
645
663
|
// gaon.config.ts — 문서형 커넥션은 adapter: 'mongodb' 로 SQL 과 나란히 선언한다.
|
|
664
|
+
import { defineConfig } from 'gaonjs/config'
|
|
665
|
+
import { env } from 'gaonjs/env'
|
|
666
|
+
|
|
646
667
|
export default defineConfig({
|
|
647
668
|
db: {
|
|
648
669
|
main: { adapter: 'postgres', url: env('DATABASE_URL') },
|
|
@@ -763,7 +784,7 @@ export const Post = model(posts, {
|
|
|
763
784
|
})
|
|
764
785
|
```
|
|
765
786
|
|
|
766
|
-
```ts
|
|
787
|
+
```ts fragment
|
|
767
788
|
// 사용 — scope·체이닝·CRUD 가 그대로 이어진다
|
|
768
789
|
const items = await Post.published().latest().limit(20).all()
|
|
769
790
|
const both = await Post.published().recent().all() // 스코프 조합
|
|
@@ -794,6 +815,10 @@ await post.publish() // 인스턴스 메서
|
|
|
794
815
|
관계를 그대로 호출한다.
|
|
795
816
|
|
|
796
817
|
```ts
|
|
818
|
+
// domain/models/Post.ts — 스코프 + 관계를 쓰는 인스턴스 메서드
|
|
819
|
+
import { model } from 'gaonjs/data'
|
|
820
|
+
import { posts } from '../schema/posts.js'
|
|
821
|
+
|
|
797
822
|
export const Post = model(posts, {
|
|
798
823
|
scopes: {
|
|
799
824
|
// 스코프는 컬럼만 다룬다 — 관계 조건이 필요하면 Post.query() 탈출구(§5)
|
|
@@ -862,7 +887,7 @@ export const Post = model(posts, {
|
|
|
862
887
|
돌려준다(원 폼 불변). 컬럼 타입·검증·기본값 정보가 그대로 따라오므로,
|
|
863
888
|
"검증되는 부분 폼"이 필요할 때 애드혹 `{ _row: {} as T }`(검증 없음) 대신 쓴다.
|
|
864
889
|
|
|
865
|
-
```ts
|
|
890
|
+
```ts controller-action
|
|
866
891
|
// routes: r.post('/posts/:postId/comments', 'comments#create')
|
|
867
892
|
// comments 스키마에서 postId·author·body 만 — :postId 는 라우트에서 자동 병합(결정 95).
|
|
868
893
|
async create() {
|
|
@@ -886,7 +911,7 @@ async create() {
|
|
|
886
911
|
|
|
887
912
|
비싼 조회·계산 결과를 **명시 TTL** 로 캐시한다. `gaonjs/data` 에서 온다.
|
|
888
913
|
|
|
889
|
-
```ts
|
|
914
|
+
```ts fragment
|
|
890
915
|
import { cache } from 'gaonjs/data'
|
|
891
916
|
|
|
892
917
|
// 키가 있으면 캐시 값, 없으면 fn 을 돌려 60초 캐시.
|
|
@@ -1090,7 +1115,7 @@ export default seed(async () => {
|
|
|
1090
1115
|
|
|
1091
1116
|
## 정본 예시
|
|
1092
1117
|
|
|
1093
|
-
```ts
|
|
1118
|
+
```ts fragment
|
|
1094
1119
|
// 목록 + 필터 + 정렬 — 대표 패턴
|
|
1095
1120
|
const posts = await Post
|
|
1096
1121
|
.where('published', '=', true)
|
|
@@ -122,7 +122,7 @@ async function search(q: string) {
|
|
|
122
122
|
(폼이 아니므로 `useForm` 이 아니고, `fetch()` 는 CSRF 미부착으로 403 — 아래
|
|
123
123
|
함정). POST 라우트 `r.post('/posts/:id/like', 'posts#like')` 기준:
|
|
124
124
|
|
|
125
|
-
```vue
|
|
125
|
+
```vue fragment
|
|
126
126
|
<script setup lang="ts">
|
|
127
127
|
import { api, isApiError } from 'gaonjs/vue'
|
|
128
128
|
|
|
@@ -195,12 +195,17 @@ bigint PK(`t.id()`)를 페이지로 흘릴 때는 **컨트롤러 render props
|
|
|
195
195
|
|
|
196
196
|
```ts
|
|
197
197
|
// apps/web/controllers/products.ts
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
import { controller } from 'gaonjs/web'
|
|
199
|
+
import { Product } from '../../../domain/models/Product.js'
|
|
200
|
+
|
|
201
|
+
export default controller({
|
|
202
|
+
async index() {
|
|
203
|
+
const list = await Product.orderBy('createdAt', 'desc').limit(20).all()
|
|
204
|
+
return this.render('Products/Index', {
|
|
205
|
+
products: list.map((p) => ({ id: String(p.id), name: p.name, price: p.price })),
|
|
206
|
+
})
|
|
207
|
+
},
|
|
208
|
+
})
|
|
204
209
|
```
|
|
205
210
|
|
|
206
211
|
- **정규화 지점은 하나** — 컨트롤러. Vue 템플릿에서 `:key="String(p.id)"` 로 방어
|
|
@@ -310,7 +315,7 @@ shared/lib/utils.ts # cn() — 조건부 클래스 병합
|
|
|
310
315
|
shared/components/ui/*.vue # 원자 + 블록 (전 앱 공용 순수 UI)
|
|
311
316
|
```
|
|
312
317
|
|
|
313
|
-
```vue
|
|
318
|
+
```vue fragment
|
|
314
319
|
<script setup lang="ts">
|
|
315
320
|
import Button from '@shared/components/ui/Button.vue' // @shared = 프로젝트 shared/
|
|
316
321
|
import Card from '@shared/components/ui/Card.vue'
|
|
@@ -420,7 +425,7 @@ import PageShell from '@shared/components/ui/PageShell.vue'
|
|
|
420
425
|
`import.meta` 를 **TS1470** 로 거부하기 때문이다(`gaon check` red). 프레임웍이
|
|
421
426
|
`import.meta.env` 를 대신 읽어 재노출하므로 페이지는 `import.meta` 를 안 쓴다.
|
|
422
427
|
|
|
423
|
-
```vue
|
|
428
|
+
```vue fragment
|
|
424
429
|
<script setup lang="ts">
|
|
425
430
|
import { env } from 'gaonjs/vue'
|
|
426
431
|
|
|
@@ -43,6 +43,8 @@ apps/
|
|
|
43
43
|
|
|
44
44
|
```ts
|
|
45
45
|
// gaon.config.ts
|
|
46
|
+
import { defineConfig } from 'gaonjs/config'
|
|
47
|
+
|
|
46
48
|
export default defineConfig({
|
|
47
49
|
i18n: { fallbackLng: 'ko', supportedLngs: ['ko', 'en'] },
|
|
48
50
|
})
|
|
@@ -78,7 +80,13 @@ const props = pageProps<'web:posts#index'>()
|
|
|
78
80
|
|
|
79
81
|
```ts
|
|
80
82
|
// apps/web/main.ts
|
|
83
|
+
import { createGaonApp } from 'gaonjs/vue'
|
|
84
|
+
import { routes } from './.gaon/routes.manifest.js'
|
|
81
85
|
import { catalogs, fallbackLng } from './.gaon/messages.catalog.js'
|
|
86
|
+
|
|
87
|
+
const pages = import.meta.glob('./pages/**/*.vue')
|
|
88
|
+
const layouts = import.meta.glob('./layouts/*.vue', { eager: true })
|
|
89
|
+
|
|
82
90
|
void createGaonApp({ pages, layouts, routes, messages: { catalogs, fallbackLng } })
|
|
83
91
|
```
|
|
84
92
|
|
|
@@ -86,13 +94,21 @@ void createGaonApp({ pages, layouts, routes, messages: { catalogs, fallbackLng }
|
|
|
86
94
|
|
|
87
95
|
```ts
|
|
88
96
|
// domain/mails/welcome.ts — 수신자 로케일로 렌더된다(결정 160)
|
|
97
|
+
import { mail } from 'gaonjs/mail'
|
|
89
98
|
import { t } from 'gaonjs/i18n'
|
|
90
|
-
|
|
99
|
+
|
|
100
|
+
export const Welcome = mail<{ name: string; email: string }>((u) => ({
|
|
101
|
+
to: u.email,
|
|
102
|
+
subject: t('mail.welcome.subject', { name: u.name }),
|
|
103
|
+
html: `<h1>${t('mail.welcome.body')}</h1>`,
|
|
104
|
+
}))
|
|
91
105
|
```
|
|
92
106
|
|
|
93
107
|
```ts
|
|
94
108
|
// domain/jobs/sendDigest.ts — 잡은 요청 밖이라 로케일을 페이로드로 싣는다
|
|
109
|
+
import { job } from 'gaonjs/async'
|
|
95
110
|
import { runWithLanguage, t } from 'gaonjs/i18n'
|
|
111
|
+
|
|
96
112
|
export const SendDigest = job(async ({ locale }: { locale: string }) => {
|
|
97
113
|
const line = runWithLanguage(locale, () => t('mail.digest.body'))
|
|
98
114
|
})
|
|
@@ -114,7 +130,7 @@ export const SendDigest = job(async ({ locale }: { locale: string }) => {
|
|
|
114
130
|
`gaon gen`/`dev`/`check` 가 카탈로그를 읽어 **두 유니온**을 생성한다:
|
|
115
131
|
서버(`gaonjs/i18n`) = backend ∪ frontend / 클라(`gaonjs/vue`) = frontend 만.
|
|
116
132
|
|
|
117
|
-
```ts
|
|
133
|
+
```ts fragment expect-error
|
|
118
134
|
t('posts.heading') // ✅
|
|
119
135
|
t('mail.welcome.subject', { name }) // ❌ 클라에서 backend 키 — 컴파일 에러
|
|
120
136
|
t('greeting') // ❌ {{name}} 보간 파라미터 누락 — 컴파일 에러
|
|
@@ -152,7 +168,7 @@ t('cart.items', { count: 'many' }) // ❌ count 는 number — 컴파일 에
|
|
|
152
168
|
`gaon.config.ts` 에 `i18n` 이 있으면 매 요청 **세션 > 쿠키 > Accept-Language** 로 협상된다.
|
|
153
169
|
전환은 서버가 권위를 갖는다 — 클라에 `setLocale` 은 **없다**.
|
|
154
170
|
|
|
155
|
-
```ts
|
|
171
|
+
```ts controller-action
|
|
156
172
|
// 컨트롤러 — 언어 전환 라우트
|
|
157
173
|
async switchLocale() {
|
|
158
174
|
const { lng } = this.params({ _row: {} as { lng: string } })
|
|
@@ -177,7 +193,7 @@ async switchLocale() {
|
|
|
177
193
|
런타임까지 오는 것은 **동적 키**(`t(변수)`)와 진짜 카탈로그 누락뿐이라, 그 둘만 우아하게
|
|
178
194
|
degrade 한다. 즉 **빌드 = 차단 / 런타임 = 키 렌더 + 경고**의 2단이다.
|
|
179
195
|
|
|
180
|
-
```ts
|
|
196
|
+
```ts fragment
|
|
181
197
|
const key = `posts.${kind}` // 동적 키 — 컴파일 게이트가 못 본다
|
|
182
198
|
t(key as never) // 없으면 'posts.abc' 가 화면에 뜨고 경고 1줄
|
|
183
199
|
```
|
|
@@ -209,7 +225,7 @@ t(key as never) // 없으면 'posts.abc' 가 화면에
|
|
|
209
225
|
// apps/web/locales/en.json ← 같은 키를 나란히(locale-parity)
|
|
210
226
|
{ "posts": { "heading": "Posts", "empty": "No posts yet." } }
|
|
211
227
|
```
|
|
212
|
-
```vue
|
|
228
|
+
```vue fragment
|
|
213
229
|
<script setup lang="ts">
|
|
214
230
|
import { t } from 'gaonjs/vue'
|
|
215
231
|
</script>
|
|
@@ -244,7 +260,7 @@ import { t, Link } from 'gaonjs/vue'
|
|
|
244
260
|
// locales/ko/backend.json
|
|
245
261
|
{ "mail": { "welcome": { "subject": "{{name}}님, 환영합니다" } } }
|
|
246
262
|
```
|
|
247
|
-
```ts
|
|
263
|
+
```ts fragment
|
|
248
264
|
import { t } from 'gaonjs/i18n'
|
|
249
265
|
const subject = t('mail.welcome.subject', { name: user.name })
|
|
250
266
|
```
|
|
@@ -39,7 +39,7 @@ export const WelcomeMail = mail<{ name: string; email: string }>((u) => ({
|
|
|
39
39
|
로케일은 앱이 `recipient.locale` 로 넘긴다(프레임웍이 모델 구조를 알지 않는다).
|
|
40
40
|
`locale` 생략 시 현재 요청 로케일(없으면 fallback).
|
|
41
41
|
|
|
42
|
-
```ts
|
|
42
|
+
```ts fragment
|
|
43
43
|
await WelcomeMail.deliver({ name: user.name, email: user.email }, { locale: user.locale })
|
|
44
44
|
// to 옵션으로 수신자를 데이터 밖에서 덮어쓸 수도 있다:
|
|
45
45
|
await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
|
|
@@ -71,6 +71,8 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
|
|
|
71
71
|
|
|
72
72
|
```ts
|
|
73
73
|
// gaon.config.ts
|
|
74
|
+
import { defineConfig } from 'gaonjs/config'
|
|
75
|
+
|
|
74
76
|
export default defineConfig({
|
|
75
77
|
mail: process.env.SMTP_HOST
|
|
76
78
|
? {
|
|
@@ -132,6 +132,7 @@ export default channel({
|
|
|
132
132
|
// apps/web/controllers/posts.ts — service·job·listener 어디서든 동일하게 호출
|
|
133
133
|
import { controller } from 'gaonjs/web'
|
|
134
134
|
import { broadcast } from 'gaonjs/async'
|
|
135
|
+
import { Post } from '../../../domain/models/Post.js'
|
|
135
136
|
|
|
136
137
|
export default controller({
|
|
137
138
|
async create() {
|
|
@@ -244,16 +245,16 @@ export default channel({
|
|
|
244
245
|
})
|
|
245
246
|
```
|
|
246
247
|
|
|
247
|
-
```ts
|
|
248
|
+
```ts fragment
|
|
248
249
|
// 클라이언트 — 인스턴스 키를 지정해 구독한다(경로: /gaon/ws/match/42)
|
|
249
250
|
const game = useChannel('match', { instance: matchId }) // 숫자는 자동 문자열화
|
|
250
|
-
const comments = useChannel('thread', { instance: postId })
|
|
251
|
+
const comments = useChannel('thread', { instance: String(postId) }) // bigint PK 는 String() (결정 37)
|
|
251
252
|
```
|
|
252
253
|
|
|
253
|
-
```ts
|
|
254
|
+
```ts fragment
|
|
254
255
|
// 서버 발화·조회 — { instance } 옵션으로 특정 인스턴스에만 스코프한다
|
|
255
256
|
broadcast('match', { round: 2 }, { instance: '42' }) // match:42 전원
|
|
256
|
-
await sendToUsers('match', userId, { note: '…' }, { instance: '42' }) // match:42 의 그 유저만
|
|
257
|
+
await sendToUsers('match', String(userId), { note: '…' }, { instance: '42' }) // match:42 의 그 유저만
|
|
257
258
|
const players = await presenceList('match', { instance: '42' }) // match:42 로스터
|
|
258
259
|
const rooms = await presenceOf(`user:${userId}`) // 역방향: 이 유저가 지금 있는 곳
|
|
259
260
|
// rooms = [{ channel: 'match', instance: '42' }, { channel: 'lobby' }, …]
|
|
@@ -406,10 +407,16 @@ export default channel({})
|
|
|
406
407
|
동적 정원이 된다:
|
|
407
408
|
|
|
408
409
|
```ts
|
|
409
|
-
//
|
|
410
|
+
// domain/channels/chat.ts — 채팅방: 고정 인원 제한
|
|
411
|
+
import { channel } from 'gaonjs/async'
|
|
412
|
+
|
|
410
413
|
export default channel({ instance: true, maxMembers: 100 })
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
// domain/channels/match.ts — 게임 매치: 방별 정원(메타·DB 조회)
|
|
418
|
+
import { channel, instanceMeta } from 'gaonjs/async'
|
|
411
419
|
|
|
412
|
-
// 게임 매치 — 방별 정원(메타·DB 조회)
|
|
413
420
|
export default channel({
|
|
414
421
|
instance: true,
|
|
415
422
|
async maxMembers(ctx) {
|
|
@@ -426,9 +433,9 @@ export default channel({
|
|
|
426
433
|
present 대상 수(0 = 부재 = no-op 멱등). **누가 kick 할 수 있는가는 호출
|
|
427
434
|
지점(컨트롤러·서비스)의 앱 인가가 정한다.**
|
|
428
435
|
|
|
429
|
-
```ts
|
|
436
|
+
```ts fragment
|
|
430
437
|
// 채팅 모더레이터 강퇴 / 게임 안티치트 축출 — 같은 한 줄
|
|
431
|
-
await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위반' })
|
|
438
|
+
await kick('room', `user:${targetId}`, { instance: String(roomId), reason: '규정 위반' })
|
|
432
439
|
```
|
|
433
440
|
|
|
434
441
|
**버튼 → 컨트롤러 → `api()` 완결 경로(결정 452).** kick 은 서버 전용
|
|
@@ -437,10 +444,10 @@ await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위
|
|
|
437
444
|
(`agents/frontend.md` §2). 위임(delegate)·방 설정 변경류 **방장 커맨드도
|
|
438
445
|
전부 같은 경로**다:
|
|
439
446
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
r.post('/rooms/:key/kick', 'rooms#kick')
|
|
447
|
+
라우트는 한 줄이다 — `apps/web/routes.ts` 의 `routes((r) => { … })` 안에
|
|
448
|
+
`r.post('/rooms/:key/kick', 'rooms#kick')` 을 더한다.
|
|
443
449
|
|
|
450
|
+
```ts controller-action
|
|
444
451
|
// apps/web/controllers/rooms.ts — 인가(방장 검사) → kick → this.json
|
|
445
452
|
async kick() {
|
|
446
453
|
this.requireAuth()
|
|
@@ -454,8 +461,8 @@ async kick() {
|
|
|
454
461
|
}
|
|
455
462
|
```
|
|
456
463
|
|
|
457
|
-
```ts
|
|
458
|
-
// apps/web/pages/Rooms/Show.vue — CSRF 는 api() 가 자동 부착(결정 166·341)
|
|
464
|
+
```ts fragment
|
|
465
|
+
// apps/web/pages/Rooms/Show.vue 의 <script setup> — CSRF 는 api() 가 자동 부착(결정 166·341)
|
|
459
466
|
import { api, isApiError } from 'gaonjs/vue'
|
|
460
467
|
await api('web:rooms#kick', { key: room.key, targetUserId }) // :key 는 자리표시자, 나머지는 JSON 바디
|
|
461
468
|
```
|
|
@@ -470,23 +477,34 @@ await api('web:rooms#kick', { key: room.key, targetUserId }) // :key 는 자
|
|
|
470
477
|
도메인마다 달라 프레임웍이 정하지 않는다 — DB(정본) + authorize(보증) +
|
|
471
478
|
kick(즉시성):
|
|
472
479
|
|
|
473
|
-
```ts
|
|
474
|
-
// ① 도메인 기록(정본 = DB) ② 즉시 축출
|
|
480
|
+
```ts fragment
|
|
481
|
+
// ① 도메인 기록(정본 = DB) ② 즉시 축출
|
|
475
482
|
await RoomBan.create({ roomId, userId, reason })
|
|
476
|
-
await kick('room', `user:${userId}`, { instance: roomId, reason })
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
483
|
+
await kick('room', `user:${userId}`, { instance: String(roomId), reason })
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
③ 재입장 차단(4401)은 `authorize` 가 보증 층이다 — kick 유실·재접속을 막는 백스톱:
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
// domain/channels/room.ts — 공유 채널 정의는 domain/channels/ 가 정본(결정 456)
|
|
490
|
+
import { channel } from 'gaonjs/async'
|
|
491
|
+
import { RoomBan } from '../models/Room.js'
|
|
492
|
+
|
|
493
|
+
export default channel({
|
|
494
|
+
instance: true,
|
|
495
|
+
async authorize(ctx) {
|
|
496
|
+
const u = ctx.user as { id: bigint } | null
|
|
497
|
+
if (!u) return false
|
|
498
|
+
let roomId: bigint
|
|
499
|
+
try {
|
|
500
|
+
roomId = BigInt(ctx.instance)
|
|
501
|
+
} catch {
|
|
502
|
+
return false
|
|
503
|
+
}
|
|
504
|
+
if (String(roomId) !== ctx.instance) return false // 정규형만(§2.7 유령 인스턴스 · 결정 447)
|
|
505
|
+
return !(await RoomBan.where('roomId', '=', roomId).where('userId', '=', u.id).exists())
|
|
506
|
+
},
|
|
507
|
+
})
|
|
490
508
|
```
|
|
491
509
|
|
|
492
510
|
익명 멤버(`conn:<uuid>`)는 신원 영속이 없어 ban 이 불가능하다(kick 만
|
|
@@ -499,13 +517,18 @@ DB + authorize** 고, 메타의 존재 이유는 닫힘 시 자동 소멸이다(
|
|
|
499
517
|
소멸 앵커가 없어 메타 미지원 · 4400):
|
|
500
518
|
|
|
501
519
|
```ts
|
|
520
|
+
// domain/channels/match.ts — 열림 시 1회 기록되는 인스턴스 메타
|
|
521
|
+
import { channel } from 'gaonjs/async'
|
|
522
|
+
|
|
502
523
|
export default channel({
|
|
503
524
|
instance: true,
|
|
504
525
|
instanceMeta(ctx) { // 열림(첫 점유) 시 1회 기록 — 허브가 open 앵커에서만
|
|
505
526
|
return { mode: ctx.query.mode ?? 'ranked', capacity: 4 }
|
|
506
527
|
},
|
|
507
528
|
})
|
|
529
|
+
```
|
|
508
530
|
|
|
531
|
+
```ts fragment
|
|
509
532
|
await setInstanceMeta('match', { mode: 'casual' }, { instance: '42' }) // 인가된 액션 갱신(LWW)
|
|
510
533
|
const meta = await instanceMeta('match', { instance: '42' }) // 읽기(미설정·닫힘 = null)
|
|
511
534
|
const rooms = await instancesOf('match', { meta: true }) // 로비: [{ instance, meta }]
|
|
@@ -523,7 +546,7 @@ const rooms = await instancesOf('match', { meta: true }) // 로
|
|
|
523
546
|
로스터로 한 곳에서 결정" 하는 로직의 정본 앵커다. `InstanceOpened/Closed` 와
|
|
524
547
|
같은 전달(워커 하나만 처리 · at-least-once — 핸들러 멱등):
|
|
525
548
|
|
|
526
|
-
```ts
|
|
549
|
+
```ts fragment
|
|
527
550
|
export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
|
|
528
551
|
// roster = 제거 반영 후 잔존 멤버 [{ id, joinSeq? }] · joinSeq 오름차순.
|
|
529
552
|
// 빈 배열 = 마지막 이탈(InstanceClosed 도 발행되지만 컨슈머 축이 달라
|
|
@@ -575,7 +598,7 @@ export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
|
|
|
575
598
|
`ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
|
|
576
599
|
허브(KV) 이므로, 웹서버가 여러 대여도 같은 목록을 본다.
|
|
577
600
|
|
|
578
|
-
```ts
|
|
601
|
+
```ts fragment
|
|
579
602
|
const members = await ctx.presence()
|
|
580
603
|
// [{ id: 'user:1', info: { name: '가온' } }, …]
|
|
581
604
|
```
|
|
@@ -74,7 +74,9 @@ npm i @gaonjs/seal # pnpm add @gaonjs/seal · yarn add @gaonjs/seal 동
|
|
|
74
74
|
2) 앱 설정에서 켠다:
|
|
75
75
|
|
|
76
76
|
```ts
|
|
77
|
-
// apps
|
|
77
|
+
// apps/web/app.config.ts — 앱 wire 전체 봉인(요청/응답 JSON + 최초 문서 data-page).
|
|
78
|
+
import { defineAppConfig } from 'gaonjs/config'
|
|
79
|
+
|
|
78
80
|
export default defineAppConfig({
|
|
79
81
|
seal: true, // 또는 { except: ['/webhooks/*'], strictQuery: true } — except = 외부가 seal 을 모르는 경로만 평문 통과 · strictQuery = 평문 쿼리도 거부(결정 354)
|
|
80
82
|
})
|
|
@@ -82,10 +84,15 @@ export default defineAppConfig({
|
|
|
82
84
|
3) 클라이언트를 배선한다:
|
|
83
85
|
|
|
84
86
|
```ts
|
|
85
|
-
// apps
|
|
87
|
+
// apps/web/main.ts — seal 클라이언트를 정적 import 해 createGaonApp 에 넘긴다.
|
|
86
88
|
import { createGaonApp } from 'gaonjs/vue'
|
|
87
89
|
import * as sealClient from '@gaonjs/seal/client' // 정적 import (사용자 vite 가 wasm 포함 번들)
|
|
88
|
-
|
|
90
|
+
import { routes } from './.gaon/routes.manifest.js'
|
|
91
|
+
|
|
92
|
+
const pages = import.meta.glob('./pages/**/*.vue')
|
|
93
|
+
const layouts = import.meta.glob('./layouts/*.vue', { eager: true })
|
|
94
|
+
|
|
95
|
+
void createGaonApp({ pages, layouts, routes, sealClient })
|
|
89
96
|
```
|
|
90
97
|
|
|
91
98
|
- **왜 main.ts 배선이 필요한가 (결정 124 · §3.1)**: `@gaonjs/vue` 는 선택 플러그인 seal 을 **몰라야** 한다
|
|
@@ -194,16 +201,27 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
194
201
|
|
|
195
202
|
```ts
|
|
196
203
|
// apps/web/app.config.ts — 앱 wire 전체 봉인.
|
|
204
|
+
import { defineAppConfig } from 'gaonjs/config'
|
|
205
|
+
|
|
197
206
|
export default defineAppConfig({ seal: true })
|
|
198
207
|
```
|
|
199
208
|
```ts
|
|
200
209
|
// apps/web/main.ts — seal 클라이언트 정적 import 를 createGaonApp 에 주입(결정 124 · doctor 가 강제·fixer 자동 배선).
|
|
201
210
|
import { createGaonApp } from 'gaonjs/vue'
|
|
202
211
|
import * as sealClient from '@gaonjs/seal/client'
|
|
203
|
-
|
|
212
|
+
import { routes } from './.gaon/routes.manifest.js'
|
|
213
|
+
|
|
214
|
+
const pages = import.meta.glob('./pages/**/*.vue')
|
|
215
|
+
const layouts = import.meta.glob('./layouts/*.vue', { eager: true })
|
|
216
|
+
|
|
217
|
+
void createGaonApp({ pages, layouts, routes, sealClient })
|
|
204
218
|
```
|
|
205
219
|
```ts
|
|
220
|
+
// apps/web/controllers/posts.ts
|
|
206
221
|
// 컨트롤러는 그대로 — seal 을 전혀 모른다(요청/응답 JSON·최초 문서 data-page·WS 프레임이 자동 봉인).
|
|
222
|
+
import { controller } from 'gaonjs/web'
|
|
223
|
+
import { Post } from '../../../domain/models/Post.js'
|
|
224
|
+
|
|
207
225
|
export default controller({
|
|
208
226
|
async index() {
|
|
209
227
|
return this.render('Posts/Index', { posts: await Post.latest().all() })
|
|
@@ -52,21 +52,26 @@
|
|
|
52
52
|
### 2. 설정 (`gaon.config.ts`)
|
|
53
53
|
|
|
54
54
|
```ts
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
55
|
+
// gaon.config.ts
|
|
56
|
+
import { defineConfig } from 'gaonjs/config'
|
|
57
|
+
|
|
58
|
+
export default defineConfig({
|
|
59
|
+
storage: process.env.STORAGE_ENDPOINT
|
|
60
|
+
? {
|
|
61
|
+
default: 'main',
|
|
62
|
+
disks: {
|
|
63
|
+
main: {
|
|
64
|
+
driver: 's3', // 's3' | 'local'
|
|
65
|
+
bucket: process.env.STORAGE_BUCKET ?? 'myapp',
|
|
66
|
+
endpoint: process.env.STORAGE_ENDPOINT, // MinIO/R2 = 필수 · AWS S3 = 생략
|
|
67
|
+
accessKeyId: process.env.STORAGE_ACCESS_KEY,
|
|
68
|
+
secretAccessKey: process.env.STORAGE_SECRET_KEY,
|
|
69
|
+
// publicUrl: 'https://cdn.example.com', // 있으면 url()이 공개 URL
|
|
70
|
+
},
|
|
66
71
|
},
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
}
|
|
73
|
+
: undefined,
|
|
74
|
+
})
|
|
70
75
|
```
|
|
71
76
|
|
|
72
77
|
- dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
|
|
@@ -86,6 +91,9 @@ storage: process.env.STORAGE_ENDPOINT
|
|
|
86
91
|
|
|
87
92
|
```ts
|
|
88
93
|
// apps/web/controllers/profile.ts
|
|
94
|
+
import { controller } from 'gaonjs/web'
|
|
95
|
+
import { Storage } from 'gaonjs/storage'
|
|
96
|
+
|
|
89
97
|
export default controller({
|
|
90
98
|
async updateAvatar() {
|
|
91
99
|
const f = this.file('avatar') // UploadedFile | undefined
|
|
@@ -139,7 +147,7 @@ export default controller({
|
|
|
139
147
|
|
|
140
148
|
## 정본 예시
|
|
141
149
|
|
|
142
|
-
```ts
|
|
150
|
+
```ts fragment
|
|
143
151
|
// 저장 → 공개/서명 URL 얻기(드라이버 무관 · 같은 코드). url() 은 async.
|
|
144
152
|
// 화면에 표시할 파일이면 키를 public/ 아래에 둔다 — 로컬 디스크의 서빙 범위가
|
|
145
153
|
// publicPrefix(기본 'public/')로 한정되기 때문이다(결정 401 · 밖이면 404).
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
|
|
57
57
|
### 3. 실 NATS 접속 관례
|
|
58
58
|
|
|
59
|
-
```ts
|
|
59
|
+
```ts test
|
|
60
60
|
import { connectNats } from 'gaonjs/async'
|
|
61
61
|
|
|
62
62
|
// 인자 없이 부르면 env NATS_URL(없으면 nats://127.0.0.1:4222)에 붙는다.
|
|
@@ -147,7 +147,7 @@ describe('PostPublished (실 NATS JetStream)', () => {
|
|
|
147
147
|
표준 하네스(`connectTestDatabase` · `config.nats` + `main` 커넥션)가 배선하므로, `gaon test`
|
|
148
148
|
로 도는 통합 테스트에서 그냥 성립한다.
|
|
149
149
|
|
|
150
|
-
```ts
|
|
150
|
+
```ts test
|
|
151
151
|
// service() 가 emit 하는 흐름도 같은 한 줄로 확증된다(트리거만 서비스 호출로 바꾸면 됨).
|
|
152
152
|
await expectEventProcessed(PostPublished, () => PublishPost.call({ postId: 1n }), { nats })
|
|
153
153
|
```
|
|
@@ -190,7 +190,7 @@ DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test`
|
|
|
190
190
|
|
|
191
191
|
스캐폴드가 심어 주는 `test/setup.ts`(수정 불필요):
|
|
192
192
|
|
|
193
|
-
```ts
|
|
193
|
+
```ts test
|
|
194
194
|
import { afterAll, afterEach, beforeAll } from 'vitest'
|
|
195
195
|
import { connectTestDatabase, truncateAllConnections, type TestDbHandle } from 'gaonjs/testing'
|
|
196
196
|
|
|
@@ -273,7 +273,7 @@ describe('AuditLog(실 mongod)', () => {
|
|
|
273
273
|
서비스가 반환하기 **전에** 실행되므로, 테스트는 서비스 호출을 `await` 한 직후 몽고
|
|
274
274
|
문서를 단언하면 된다(별도 대기·폴링 불필요).
|
|
275
275
|
|
|
276
|
-
```ts
|
|
276
|
+
```ts test
|
|
277
277
|
// SQL 커밋 → 몽고 감사 로그 순서를 한 흐름으로 확증한다.
|
|
278
278
|
const user = await SignUp.call({ email: 'a@b.c', password: 'x' }) // main(SQL) 커밋 + afterCommit
|
|
279
279
|
expect(await AuditLog.countDocuments({ actorId: String(user.id) })).toBe(1)
|
|
@@ -298,7 +298,7 @@ expect(await AuditLog.countDocuments({ actorId: 'dup' })).toBe(0)
|
|
|
298
298
|
정본 = "직렬화 결과 어디에도 hidden 컬럼명이 없다" 를 **재귀로** 단언한다
|
|
299
299
|
(배열·중첩 관계 전부 훑는 헬퍼). 관계를 포함한 픽스처에 적용한다:
|
|
300
300
|
|
|
301
|
-
```ts
|
|
301
|
+
```ts test
|
|
302
302
|
// 직렬화 결과(JSON-safe)에 hidden 컬럼명이 어느 깊이에도 없음을 확인.
|
|
303
303
|
function assertNoHiddenLeak(value: unknown, hidden: readonly string[]): void {
|
|
304
304
|
const walk = (v: unknown): void => {
|
|
@@ -231,7 +231,7 @@ handler 안에서 파싱). **결정 342 이후 헤더 부착은 프레임웍 자
|
|
|
231
231
|
(참고: 지원 Content-Type 은 `application/json` · `multipart/form-data` 뿐이라
|
|
232
232
|
`x-www-form-urlencoded` 로 폼을 보내면 415 로 거부된다 · `inertia.ts` · §아래 415.)
|
|
233
233
|
|
|
234
|
-
```vue
|
|
234
|
+
```vue fragment
|
|
235
235
|
<script setup lang="ts">
|
|
236
236
|
import { useForm } from 'gaonjs/vue'
|
|
237
237
|
const form = useForm({ avatar: null as File | null })
|
|
@@ -259,8 +259,10 @@ redirect 로 처리한다 — 전체 페이지 리로드도, 별도 REST 엔드
|
|
|
259
259
|
폼 API 는 **`gaonjs/vue` 의 `useForm`·`router`** 뿐이다(결정 64) —
|
|
260
260
|
`@inertiajs/vue3` 를 직접 import 하지 않는다(파사드가 The One Way).
|
|
261
261
|
|
|
262
|
-
```ts
|
|
262
|
+
```ts fragment
|
|
263
263
|
// 로그인 폼 — 제출은 Inertia SPA 방식, 서버는 redirect 로 답한다.
|
|
264
|
+
import { pageProps, router, useForm } from 'gaonjs/vue'
|
|
265
|
+
|
|
264
266
|
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
265
267
|
// CSRF 토큰은 프레임웍이 자동 부착한다(결정 342) — _csrf 바디도, 수동 헤더도 없다.
|
|
266
268
|
const props = pageProps<'web:session#new'>()
|
|
@@ -282,7 +284,7 @@ Inertia 폼(`useForm(...).post()`)의 검증 실패는 컨트롤러가 손으로
|
|
|
282
284
|
방문의 `errors` 로 `form.errors.<필드>` 를 **자동으로 채운다**. 입력값은
|
|
283
285
|
`useForm` 이 그대로 보존한다(재제출 방지). 컨트롤러는 성공 경로만 쓴다:
|
|
284
286
|
|
|
285
|
-
```ts
|
|
287
|
+
```ts controller-action
|
|
286
288
|
// 컨트롤러 — 검증 실패 분기를 손으로 쓰지 않는다(결정 109).
|
|
287
289
|
async create() {
|
|
288
290
|
const data = this.params(Post.form.pick('title', 'body')) // 실패 시 프레임웍이 303 back
|
|
@@ -291,10 +293,18 @@ async create() {
|
|
|
291
293
|
}
|
|
292
294
|
```
|
|
293
295
|
|
|
294
|
-
```vue
|
|
296
|
+
```vue fragment
|
|
295
297
|
<!-- 페이지 — form.errors.<필드> 는 서버 검증 실패 시 자동으로 채워진다. -->
|
|
296
|
-
<
|
|
297
|
-
|
|
298
|
+
<script setup lang="ts">
|
|
299
|
+
import { useForm } from 'gaonjs/vue'
|
|
300
|
+
|
|
301
|
+
const form = useForm({ title: '', body: '' })
|
|
302
|
+
</script>
|
|
303
|
+
|
|
304
|
+
<template>
|
|
305
|
+
<input v-model="form.title" />
|
|
306
|
+
<p v-if="form.errors.title">{{ form.errors.title }}</p>
|
|
307
|
+
</template>
|
|
298
308
|
```
|
|
299
309
|
|
|
300
310
|
순수 JSON/API 앱(X-Inertia 아님·세션 없음)은 기존대로 **422 JSON** 을 받는다.
|
|
@@ -381,6 +391,8 @@ async create() {
|
|
|
381
391
|
|
|
382
392
|
```ts
|
|
383
393
|
// apps/web/app.config.ts
|
|
394
|
+
import { defineAppConfig } from 'gaonjs/config'
|
|
395
|
+
|
|
384
396
|
export default defineAppConfig({
|
|
385
397
|
sharedProps: (ctx) => ({ locale: ctx.session?.locale ?? 'en', theme: 'dark' }),
|
|
386
398
|
})
|
|
@@ -414,7 +426,7 @@ shared.locale // 로그인/로그아웃·플래시로
|
|
|
414
426
|
메서드(스코프)**, 대등한 조합이면 `domain/services/`(`agents/data.md` §8). 컨트롤러가
|
|
415
427
|
`pluck`·교집합·사이드바 질의를 인라인 조립하기 시작하면 서비스/스코프로 옮긴다.
|
|
416
428
|
|
|
417
|
-
```ts
|
|
429
|
+
```ts fragment
|
|
418
430
|
// ❌ 컨트롤러가 검색 교집합·태그 필터를 인라인 조립
|
|
419
431
|
// ✅ const rows = await Post.searchPublished(term).latest().offset(o).limit(n).all()
|
|
420
432
|
// ✅ 페이지네이션은 스코프 체인 종단 paginate — 컨트롤러는 여전히 한 줄(결정 119)
|
|
@@ -450,7 +462,7 @@ shared.locale // 로그인/로그아웃·플래시로
|
|
|
450
462
|
base64·해시를 손으로 짜지 말고** `gaonjs/web` 의 헬퍼를 쓴다(The One Way · §7 인증).
|
|
451
463
|
bcrypt(cost 10)로 해싱하며 상수시간 비교를 제공한다.
|
|
452
464
|
|
|
453
|
-
```ts
|
|
465
|
+
```ts fragment
|
|
454
466
|
import { hashPassword, verifyPassword } from 'gaonjs/web'
|
|
455
467
|
|
|
456
468
|
// 가입 — 서비스에서 평문을 해싱해 hidden 컬럼(passwordDigest)에 저장.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.0",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,19 +28,19 @@
|
|
|
28
28
|
"dist",
|
|
29
29
|
"README.md"
|
|
30
30
|
],
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""
|
|
33
|
-
},
|
|
34
31
|
"dependencies": {
|
|
35
|
-
"@gaonjs/async": "workspace:*",
|
|
36
|
-
"@gaonjs/config": "workspace:*",
|
|
37
|
-
"@gaonjs/core": "workspace:*",
|
|
38
|
-
"@gaonjs/data": "workspace:*",
|
|
39
|
-
"@gaonjs/i18n": "workspace:*",
|
|
40
|
-
"@gaonjs/mail": "workspace:*",
|
|
41
|
-
"@gaonjs/web": "workspace:*",
|
|
42
32
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
43
33
|
"typescript": "^5.9.0",
|
|
44
|
-
"vite": "^7.0.0"
|
|
34
|
+
"vite": "^7.0.0",
|
|
35
|
+
"@gaonjs/data": "0.26.2",
|
|
36
|
+
"@gaonjs/i18n": "0.4.1",
|
|
37
|
+
"@gaonjs/config": "0.25.12",
|
|
38
|
+
"@gaonjs/async": "0.22.0",
|
|
39
|
+
"@gaonjs/mail": "0.5.3",
|
|
40
|
+
"@gaonjs/core": "0.3.0",
|
|
41
|
+
"@gaonjs/web": "0.32.1"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""
|
|
45
45
|
}
|
|
46
|
-
}
|
|
46
|
+
}
|