@gaonjs/cli 0.15.0 → 0.21.2
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/g.js +8 -0
- package/dist/commands/new.js +5 -0
- package/dist/dev/health.d.ts +4 -1
- package/dist/dev/health.js +5 -3
- package/dist/doctor/method-override.d.ts +5 -0
- package/dist/doctor/method-override.js +75 -0
- package/dist/doctor/route-registration.d.ts +5 -0
- package/dist/doctor/route-registration.js +77 -0
- package/dist/doctor/static-collision.d.ts +5 -0
- package/dist/doctor/static-collision.js +84 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +3 -3
- package/dist/doctor/ui-kit-wiring.d.ts +5 -0
- package/dist/doctor/ui-kit-wiring.js +93 -0
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +19 -2
- package/dist/generate.js +8 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +19 -5
- package/dist/scaffold/app-wiring.d.ts +12 -0
- package/dist/scaffold/app-wiring.js +68 -0
- package/dist/serve.d.ts +10 -0
- package/dist/serve.js +90 -2
- package/dist/templates/auth/Dashboard.vue.tpl +21 -5
- package/dist/templates/auth/Login.vue.tpl +37 -8
- package/dist/templates/auth/Signup.vue.tpl +40 -9
- package/dist/templates/project/.dockerignore.tpl +12 -0
- package/dist/templates/project/AGENTS.md.tpl +10 -5
- package/dist/templates/project/CLAUDE.md.tpl +4 -3
- package/dist/templates/project/Dockerfile.tpl +30 -0
- package/dist/templates/project/agents/async.md.tpl +19 -7
- package/dist/templates/project/agents/data.md.tpl +37 -15
- package/dist/templates/project/agents/frontend.md.tpl +81 -4
- package/dist/templates/project/agents/realtime.md.tpl +24 -17
- package/dist/templates/project/agents/security.md.tpl +11 -2
- package/dist/templates/project/agents/web.md.tpl +5 -0
- package/dist/templates/project/apps/web/composables/useGaonHealth.ts.tpl +2 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +19 -94
- package/dist/templates/project/apps/web/main.ts.tpl +4 -0
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +68 -251
- package/dist/templates/project/apps/web/static/robots.txt.tpl +4 -0
- package/dist/templates/project/apps/web/style.css.tpl +66 -0
- package/dist/templates/project/compose.prod.yaml.tpl +98 -0
- package/dist/templates/project/package.json.tpl +4 -0
- package/dist/templates/project/postcss.config.js.tpl +15 -0
- package/dist/templates/project/tailwind.config.ts.tpl +56 -0
- package/dist/templates/ui-kit/Alert.vue.tpl +23 -0
- package/dist/templates/ui-kit/AlertDescription.vue.tpl +9 -0
- package/dist/templates/ui-kit/AlertTitle.vue.tpl +9 -0
- package/dist/templates/ui-kit/Badge.vue.tpl +25 -0
- package/dist/templates/ui-kit/Button.vue.tpl +39 -0
- package/dist/templates/ui-kit/Card.vue.tpl +10 -0
- package/dist/templates/ui-kit/CardContent.vue.tpl +9 -0
- package/dist/templates/ui-kit/CardDescription.vue.tpl +9 -0
- package/dist/templates/ui-kit/CardFooter.vue.tpl +9 -0
- package/dist/templates/ui-kit/CardHeader.vue.tpl +9 -0
- package/dist/templates/ui-kit/CardTitle.vue.tpl +9 -0
- package/dist/templates/ui-kit/Dialog.vue.tpl +68 -0
- package/dist/templates/ui-kit/Form.vue.tpl +13 -0
- package/dist/templates/ui-kit/FormField.vue.tpl +16 -0
- package/dist/templates/ui-kit/FormMessage.vue.tpl +9 -0
- package/dist/templates/ui-kit/Input.vue.tpl +22 -0
- package/dist/templates/ui-kit/Label.vue.tpl +9 -0
- package/dist/templates/ui-kit/Sheet.vue.tpl +73 -0
- package/dist/templates/ui-kit/utils.ts.tpl +26 -0
- package/dist/uikit.d.ts +28 -0
- package/dist/uikit.js +138 -0
- package/dist/work.js +2 -0
- package/package.json +6 -6
|
@@ -193,8 +193,8 @@ export const posts = table('posts', {
|
|
|
193
193
|
| `pluck` | `(col)` | `Promise<Row[col][]>` | 단일 컬럼 배열 · 정렬·limit·offset 반영 |
|
|
194
194
|
| `select` | `(['a', 'b'])` | `SelectChain<Row, K>` | 부분 컬럼 — `first`/`all` 이 `Pick<Row, K>` **plain 행** 반환 (메서드·관계·update 없음) |
|
|
195
195
|
| `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
|
|
196
|
-
| `updateAll` | `(patch)` | `Promise<
|
|
197
|
-
| `deleteAll` | `()` | `Promise<
|
|
196
|
+
| `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
|
|
197
|
+
| `deleteAll` | `()` | `Promise<number>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
|
|
198
198
|
|
|
199
199
|
**집계·조인 그룹** (`Chain` · M2E · 결정 34):
|
|
200
200
|
|
|
@@ -218,18 +218,35 @@ export const posts = table('posts', {
|
|
|
218
218
|
| 메서드 | 시그니처 | 반환 | 비고 |
|
|
219
219
|
|---|---|---|---|
|
|
220
220
|
| `create` | `(data)` | `Promise<Rec>` | `t.id()`·`t.timestamps()`·`.default()` 컬럼은 입력에서 선택적 (`InsertOf`) |
|
|
221
|
-
| `batchInsert` | `(rows)` | `Promise<
|
|
222
|
-
| `insertOrIgnore` | `(row \| rows)` | `Promise<
|
|
223
|
-
| `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<
|
|
221
|
+
| `batchInsert` | `(rows)` | `Promise<BulkResult>` | **벌크 삽입** (M2F · 결정 35 → BulkResult 결정 90) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · `{ count }`(삽입 행 수) 반환. 빈 배열은 DB 무접촉 `{ count: 0 }`. beforeCreate 훅은 각 row 에 적용 |
|
|
222
|
+
| `insertOrIgnore` | `(row \| rows)` | `Promise<BulkResult>` | **멱등 삽입** (M2F) — 충돌 행은 건너뛰고 `count` = **실제로 삽입된 행 수**(PG=`ON CONFLICT DO NOTHING` · MySQL=`INSERT IGNORE`). 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
|
|
223
|
+
| `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<BulkResult>` | **있으면 갱신 없으면 삽입** (M2F) — PG=`ON CONFLICT DO UPDATE` · MySQL=`ON DUPLICATE KEY UPDATE`. `count` = 처리된 입력 행 수(삽입+갱신 · 갱신 대상 없으면 실 삽입 수). `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 덮음 |
|
|
224
224
|
| `find` | `(id)` | `Promise<Rec>` | 없으면 **throw** — undefined 를 허용하려면 `where('id', '=', id).first()` |
|
|
225
225
|
| `query` | `()` | Kysely `SelectQueryBuilder` | §5 탈출구 |
|
|
226
226
|
|
|
227
|
-
> 벌크
|
|
228
|
-
>
|
|
229
|
-
>
|
|
230
|
-
>
|
|
227
|
+
> 벌크 3종은 **전 방언 통일 `BulkResult`**(`{ count, meta? }`)를 돌려준다(결정 90 ·
|
|
228
|
+
> 원안의 "비 RETURNING 방언 throw" 폐기). `count` 는 **처리된 입력 행 수**로 방언
|
|
229
|
+
> 무관 같은 의미다 — MySQL upsert 의 원시 `affectedRows`(삽입 1·갱신 2·무변경 0/1)를
|
|
230
|
+
> 그대로 노출하지 않고 정규화한다. 방언 원시값이 필요하면 `meta`(PG `returnedIds` ·
|
|
231
|
+
> MySQL `firstInsertId`·`affectedRows`)를 쓴다. **행 자체가 필요하면**: 소량은
|
|
232
|
+
> `create()`(단건 · 전 방언 Rec 반환), 대량은 유니크 키로 재조회 — **id 범위 추정
|
|
233
|
+
> 재조회는 금지**(MySQL auto-increment 는 동시 삽입에서 연속 보장이 없다). 삽입 계열은
|
|
231
234
|
> `create` 처럼 루트 전용 — `where` 필터와 무관하므로 체인 종단이 아니다.
|
|
232
235
|
|
|
236
|
+
**개수·집계 반환 타입 — 영역별로 다르다** (결정 90·91 · 헷갈리지 말 것):
|
|
237
|
+
|
|
238
|
+
| 영역 | 반환 타입 | 근거 |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| 쓰기 개수 (`BulkResult.count` · `updateAll` · `deleteAll`) | `number` | 처리 행 수 — 2^53 초과 실무 없음 · JSON 직렬화 |
|
|
241
|
+
| 읽기 집계 `count()` | `bigint` | E-4 정본 |
|
|
242
|
+
| 읽기 집계 `sum()` · `avg()` | `string` | 무손실(정밀도 보존) |
|
|
243
|
+
| 읽기 집계 `min()` · `max()` | 컬럼 타입 따름 | E-4 정본 |
|
|
244
|
+
|
|
245
|
+
> **왜 sum/avg 가 `number` 가 아닌가**: 양 방언 실측 결과 집계 체인은 이미 방언
|
|
246
|
+
> 일관이라(silent split 없음) 고칠 문제가 없고, `string→number` 강제는 2^53 초과
|
|
247
|
+
> 합계에서 **정밀도를 잃는**(lossy) breaking 이라 "어림 금지" 원칙에 어긋나 기각했다
|
|
248
|
+
> (결정 91). 쓰기 개수(number)와 읽기 집계(string/bigint)는 축이 다르다.
|
|
249
|
+
|
|
233
250
|
**레코드(`Rec`) 내장** (`model.ts:47-52`):
|
|
234
251
|
|
|
235
252
|
- `rec.update(patch)` — `Partial<Row>` 부분 갱신, 갱신된 Rec 반환.
|
|
@@ -546,14 +563,15 @@ const slim = await Post.select(['id', 'title']).all() // Pick<Row, 'id' | 'titl
|
|
|
546
563
|
// 삭제 — 단건은 레코드, 벌크는 deleteAll (M2C)
|
|
547
564
|
const post = await Post.find(id)
|
|
548
565
|
await post.delete()
|
|
549
|
-
const removed = await Post.where('published', '=', false).deleteAll() //
|
|
566
|
+
const removed = await Post.where('published', '=', false).deleteAll() // number
|
|
550
567
|
const touched = await Post.where('authorId', '=', me.id).updateAll({ published: true })
|
|
551
568
|
|
|
552
|
-
// 벌크 삽입·UPSERT — 루트 전용 (M2F · 결정 35)
|
|
553
|
-
const seeded = await Post.batchInsert(rows) //
|
|
554
|
-
const fresh = await Post.insertOrIgnore(rows) //
|
|
555
|
-
await Post.upsert(rows, { onConflict: 'slug', update: ['title', 'body'] })
|
|
569
|
+
// 벌크 삽입·UPSERT — 루트 전용 (M2F · 결정 35 → BulkResult 결정 90 · 전 방언 통일)
|
|
570
|
+
const seeded = await Post.batchInsert(rows) // { count } (삽입 행 수)
|
|
571
|
+
const fresh = await Post.insertOrIgnore(rows) // { count } (실 삽입만 · 충돌 건너뜀)
|
|
572
|
+
const { count } = await Post.upsert(rows, { onConflict: 'slug', update: ['title', 'body'] })
|
|
556
573
|
await Post.upsert({ id, title, body }) // onConflict 생략 = 기본 키(id)
|
|
574
|
+
// 삽입된 행이 필요하면: 소량은 create(), 대량은 유니크 키로 재조회 (id 범위 추정 금지)
|
|
557
575
|
```
|
|
558
576
|
|
|
559
577
|
## 알려진 함정
|
|
@@ -568,6 +586,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
568
586
|
`foreignKey`/`otherKey` 를 명시한다.
|
|
569
587
|
- **`updateAll`/`deleteAll` 에 limit·offset·orderBy 가 걸려 있으면 throw** —
|
|
570
588
|
행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`.
|
|
589
|
+
- **벌크 3종은 행이 아니라 `{ count }` 를 준다** (결정 90) — 반환을 `Rec[]` 처럼
|
|
590
|
+
다루지 말 것. 삽입된 행이 필요하면 소량은 `create()`, 대량은 유니크 키 재조회.
|
|
591
|
+
`throw` 하던 옛 계약(비 RETURNING 방언)은 폐기 — MySQL 도 그냥 `count` 를 준다.
|
|
571
592
|
- **loop 안 관계 lazy 호출 = N+1** — doctor **n-plus-one** 검사가 잡는다.
|
|
572
593
|
목록은 `include()` 로.
|
|
573
594
|
- **스키마 파일명은 camelCase** — 테이블 `posts_tags` → 파일 `postsTags.ts`
|
|
@@ -588,8 +609,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
588
609
|
| 결정 31 | `rec.delete()` 명명(destroy 아님) · 파라미터 스코프 (M2C) |
|
|
589
610
|
| 결정 33 | 관계 선언 위치(컬럼 + relations) · 문자열 테이블명 (M2D) |
|
|
590
611
|
| 결정 34 | 집계·조인 그룹(groupBy·having·distinct·withCount·join) (M2E) |
|
|
591
|
-
| 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) |
|
|
612
|
+
| 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) → **결정 90 으로 개정** |
|
|
592
613
|
| 결정 39 | 마이그레이션 합성형(파일 replay → 스키마 diff · no-auto-drop) |
|
|
614
|
+
| 결정 90 | 벌크 3종 `BulkResult`(count+meta) 전 방언 통일 · updateAll/deleteAll bigint→number (W14) |
|
|
593
615
|
| 결정 43 | 네이밍 정본화 · DB 네이밍 SSOT(§1.2 · 테이블 snake · 컬럼 camel) |
|
|
594
616
|
| 결정 46 | doctor 컬럼(column-casing)·모델/페이지 파일명 검사 3종 |
|
|
595
617
|
| 결정 47 | `gaon g model` 다단어 테이블명 snake_case(마지막 단어 복수) |
|
|
@@ -36,6 +36,12 @@ const props = pageProps<'web:posts#index'>()
|
|
|
36
36
|
액션 → `'web:posts#index'`.
|
|
37
37
|
- **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
|
|
38
38
|
로 import 하지 않는다.
|
|
39
|
+
- **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
|
|
40
|
+
라우팅**이라 클라이언트 라우터 라이브러리가 없다. `vue-router`·`react-router`
|
|
41
|
+
를 install/import 하지 말 것(`createRouter`·`useRoute`·`useRouter`·`<RouterLink>`
|
|
42
|
+
전부 없음 · CLAUDE.md 규칙 12). 페이지 전환은 `gaonjs/vue` 의 `router`
|
|
43
|
+
(`router.visit(url)`·`router.get/post/delete`), 폼은 `useForm(...)` (결정 64).
|
|
44
|
+
라우트 정의는 서버의 `apps/<앱>/routes.ts` 뿐이다.
|
|
39
45
|
- **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
|
|
40
46
|
금지 (라우트를 모른다는 순수 규칙).
|
|
41
47
|
|
|
@@ -160,8 +166,13 @@ import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
|
|
|
160
166
|
이 엔드포인트는 `gaon dev` 에서만 등록되고(운영 빌드엔 없음), 404 면 컴포저블이
|
|
161
167
|
`available=false` 로 우아하게 degrade 한다.
|
|
162
168
|
|
|
163
|
-
-
|
|
164
|
-
|
|
169
|
+
- **HUB 카드는 실 introspection(결정 71)** — jobs = JetStream 스트림 대기(pending)
|
|
170
|
+
수 · channels = 프레즌스 KV 권위의 채널별 실 접속자 수. 조회 실패·미접속을 0/true
|
|
171
|
+
로 가장하지 않는다(과대 약속 금지) — 버킷·스트림 미생성이면 각각 null 로 degrade.
|
|
172
|
+
- **스타일은 Tailwind 유틸 + UI 킷(결정 74·75)** — 스캐폴드는 Tailwind
|
|
173
|
+
(`tailwind.config.ts`·`postcss.config.js`·`apps/<앱>/style.css`)를 편입한다.
|
|
174
|
+
랜딩·auth 페이지는 Tailwind 유틸과 UI 킷 컴포넌트(§8)로 그린다(결정 69 의
|
|
175
|
+
scoped-CSS 미편입 방침은 결정 74 로 뒤집혔다).
|
|
165
176
|
- **auth 링크는 수동(결정 70)** — `gaon g auth` 는 auth 페이지·라우트만 신설하고
|
|
166
177
|
랜딩·레이아웃 nav 를 편집하지 않는다. 헤더에 로그인 링크를 두려면 `Default.vue`
|
|
167
178
|
의 nav 에 `<a href="/session/new">로그인</a>` 을 직접 추가한다(Rails 관례).
|
|
@@ -169,6 +180,55 @@ import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
|
|
|
169
180
|
·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
|
|
170
181
|
소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
|
|
171
182
|
|
|
183
|
+
### 8. UI 킷 (결정 74·75 · shadcn 참조 · 복사-소유)
|
|
184
|
+
|
|
185
|
+
`gaon new` 는 기본 앱에 UI 킷을 심고(배터리 포함), `gaon g ui-kit [--app <앱>]`
|
|
186
|
+
로 다른 앱에도 추가한다. 컴포넌트는 **프로젝트에 복사된 여러분 코드**다 — npm
|
|
187
|
+
의존이 아니라 파일이므로 자유롭게 고친다(shadcn 방식). 외부 런타임 의존이 없다
|
|
188
|
+
(clsx·tailwind-merge·cva·reka-ui 미도입 · `cn` 은 자작 · Dialog/Sheet 는 Teleport+
|
|
189
|
+
Transition 자작).
|
|
190
|
+
|
|
191
|
+
배치·import:
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
apps/<앱>/lib/utils.ts # cn() — 조건부 클래스 병합
|
|
195
|
+
apps/<앱>/components/ui/*.vue # Button·Input·Label·Badge·Card…·Alert…·Form…·Dialog·Sheet
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
```vue
|
|
199
|
+
<script setup lang="ts">
|
|
200
|
+
import Button from '../../components/ui/Button.vue' // 앱 로컬 · 상대 경로
|
|
201
|
+
import Card from '../../components/ui/Card.vue'
|
|
202
|
+
import CardContent from '../../components/ui/CardContent.vue'
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<template>
|
|
206
|
+
<Card>
|
|
207
|
+
<CardContent>
|
|
208
|
+
<Button variant="default">저장</Button> <!-- variant·size 로 모양 선택 -->
|
|
209
|
+
</CardContent>
|
|
210
|
+
</Card>
|
|
211
|
+
</template>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- **폼은 UI 킷 Form + gaonjs `useForm`(결정 64)** — `Form` 은 얇은 `<form>` 래퍼로
|
|
215
|
+
`@submit` 을 `useForm` 의 `post/put/delete` 로 넘긴다. vee-validate 를 끌어오지
|
|
216
|
+
않는다(검증·상태는 `useForm`). `FormField label error` + `FormMessage` 로 라벨·
|
|
217
|
+
오류를 붙이고, `:error="form.errors.<field>"` 로 서버 검증을 표시한다.
|
|
218
|
+
- **`class` 는 폴스루로 병합** — 단일 루트 컴포넌트는 `<Button class="w-full">` 처럼
|
|
219
|
+
넘긴 클래스가 루트로 흘러간다(별도 `class` prop 선언 없음). `cn` 은 충돌 클래스
|
|
220
|
+
자동 해소를 하지 않는다 — 오버라이드가 잦으면 tailwind-merge 를 설치해 `cn` 만 교체.
|
|
221
|
+
- **디자인 토큰은 `style.css` 한 곳(결정 74)** — 컴포넌트는 `bg-primary`·
|
|
222
|
+
`text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
|
|
223
|
+
`:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
|
|
224
|
+
- **멀티앱은 앱마다 배선이 따로다(결정 76)** — `gaon g app admin` 은 새 앱에
|
|
225
|
+
프론트 배선(`apps/admin/{style.css,main.ts,index.html}`)을 web 앱과 동등하게
|
|
226
|
+
함께 심는다. 그래서 `gaon g ui-kit --app admin` 으로 심은 UI 킷이 Tailwind
|
|
227
|
+
유틸을 그대로 받는다(`g ui-kit --app` 은 배선이 없으면 멱등으로 보정한다).
|
|
228
|
+
`tailwind.config.ts`·`postcss.config.js` 는 프로젝트 루트 공유(앱마다 두지
|
|
229
|
+
않는다). 앱이 UI 킷을 import 하는데 그 앱에 `style.css` 배선이 없으면 doctor
|
|
230
|
+
**ui-kit-wiring** 이 경고한다.
|
|
231
|
+
|
|
172
232
|
## 정본 예시
|
|
173
233
|
|
|
174
234
|
```vue
|
|
@@ -204,7 +264,20 @@ async function runSearch(q: string) {
|
|
|
204
264
|
- **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출 금지** — doctor
|
|
205
265
|
shared-composable-purity 위반. 데이터는 props/인자로.
|
|
206
266
|
- **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
|
|
207
|
-
`
|
|
267
|
+
`gaonjs/vue` 의 `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
|
|
268
|
+
- **`vue-router` import 금지** — Gaon 은 클라이언트 라우터가 없다(Inertia =
|
|
269
|
+
SPA + 서버 라우팅). `import { useRouter } from 'vue-router'` 는 존재하지 않는
|
|
270
|
+
의존을 끌어와 컴파일 실패한다 — 전환은 `gaonjs/vue` 의 `router`, 라우트 정의는
|
|
271
|
+
`apps/<앱>/routes.ts`(§1).
|
|
272
|
+
- **로그아웃은 `router.delete('/session')`** — `?_method=DELETE` 폼 override(Rails/
|
|
273
|
+
Laravel 관례)는 Gaon 에서 안 통한다(Inertia 는 실 DELETE 를 보낸다 · 컴파일은
|
|
274
|
+
통과해도 런타임 조용히 파손). 로그아웃 링크/버튼은 `router.delete(...)` 또는
|
|
275
|
+
`useForm(...).delete(...)` 로 한다(결정 64 · 절대 규칙 6). `_method` 사용은 doctor
|
|
276
|
+
**method-override** 가 잡는다(결정 89).
|
|
277
|
+
- **채널 구독은 `useChannel()`** — 실시간 클라이언트는 `gaonjs/vue` 의
|
|
278
|
+
`useChannel(name, opts)` 가 정본(결정 87). `new WebSocket` 을 손으로 짜면
|
|
279
|
+
URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클을 재구현하다
|
|
280
|
+
틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에.
|
|
208
281
|
- **레이아웃을 shared 에 두지 않는다** — 앱별이 정상.
|
|
209
282
|
- **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
|
|
210
283
|
Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
|
|
@@ -221,6 +294,10 @@ async function runSearch(q: string) {
|
|
|
221
294
|
| 결정 25 (E-5) | 컴포저블·레이아웃 관례 · 프론트 로직 배치 3규칙 · 자동 import 금지 |
|
|
222
295
|
| 결정 37 | bigint PK 컨트롤러 `String()` 정규화 |
|
|
223
296
|
| 결정 46 | doctor page-filename(페이지 PascalCase)·model/column 검사 3종 |
|
|
224
|
-
| 결정 69 | 랜딩 정본(라이브 헬스 카드 · 다크 헤더 레이아웃 ·
|
|
297
|
+
| 결정 69 | 랜딩 정본(라이브 헬스 카드 · 다크 헤더 레이아웃 · 실 상태 · 코드 블록) |
|
|
225
298
|
| 결정 70 | auth 통합 = 수동(`gaon g auth` 는 랜딩·nav 를 안 건드림 · Rails 관례) |
|
|
299
|
+
| 결정 71 | HUB 카드 실 introspection(jobs=스트림 pending · channels=프레즌스 실 접속자 · 과대 약속 금지) |
|
|
300
|
+
| 결정 74 | Tailwind 스캐폴드 편입(tailwind.config.ts·postcss.config.js·style.css · 결정 69 scoped-CSS 방침 뒤집기) |
|
|
301
|
+
| 결정 75 | shadcn 식 UI 킷(`gaon g ui-kit` · 복사-소유 · Vue 3 신작 · 외부 런타임 의존 0) |
|
|
302
|
+
| 결정 76 | 멀티앱 UI 킷 배선 자동화(`g app`·`g ui-kit --app` 이 앱별 Tailwind 배선 동봉·멱등 보정 · doctor ui-kit-wiring) |
|
|
226
303
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
@@ -89,29 +89,36 @@ const members = await ctx.presence()
|
|
|
89
89
|
- leave 는 best-effort 이고, 서버가 죽으면 허브가 그 서버의 멤버 전원을
|
|
90
90
|
**즉시** 정리한다 (TCP `close`).
|
|
91
91
|
|
|
92
|
-
### 4. 클라이언트 (
|
|
92
|
+
### 4. 클라이언트 (`useChannel` · 결정 87)
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
흘리지 않는다. 받는 프레임도 같은 모양(`t` 로 종류를 가른다).
|
|
94
|
+
**정본 = `gaonjs/vue` 의 `useChannel(name, opts)`.** 채널 구독의 One Way 다 —
|
|
95
|
+
URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{ t:'msg', data }`)
|
|
96
|
+
감싸기/풀기·마운트 접속·언마운트 정리·반응형 상태를 한 번에 준다. `new WebSocket`
|
|
97
|
+
을 손으로 짜지 말 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
|
|
98
|
+
자동 인증, JWT 앱은 `params: { access_token }`.
|
|
100
99
|
|
|
101
100
|
```ts
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
101
|
+
// apps/web/composables/useRoom.ts — 컴포저블에 래핑(agents/frontend.md §3.2)
|
|
102
|
+
import { useChannel } from 'gaonjs/vue'
|
|
103
|
+
|
|
104
|
+
export function useRoom(roomId: number) {
|
|
105
|
+
// messages(반응형)·status·send·connect·close 를 돌려준다. 마운트에 접속.
|
|
106
|
+
const { messages, status, send } = useChannel('room', {
|
|
107
|
+
params: { room: roomId },
|
|
108
|
+
onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
|
|
109
|
+
onPresence: (delta) => { /* 접속자 join/leave */ },
|
|
110
|
+
})
|
|
111
|
+
return { messages, status, send }
|
|
110
112
|
}
|
|
111
|
-
// 보낼 때도 봉투로 감싼다 — 이래야 채널의 onMessage 가 호출된다.
|
|
112
|
-
ws.send(JSON.stringify({ t: 'msg', data: { text: '안녕하세요' } }))
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
+
- **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
|
|
116
|
+
정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
|
|
117
|
+
- **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출, `presence` 는
|
|
118
|
+
`onPresence`. 그 외는 `onFrame`.
|
|
119
|
+
- **탈출구** — 표준 WebSocket 이 필요하면 `new WebSocket('<프리픽스>/gaon/ws/<채널명>')`
|
|
120
|
+
을 직접 쓸 수 있다(봉투·라이프사이클을 스스로 책임진다). 기본 경로는 `useChannel`.
|
|
121
|
+
|
|
115
122
|
### 5. 허브 프로세스 (`gaon hub`)
|
|
116
123
|
|
|
117
124
|
허브는 접속자 목록의 단일 권위이자 서버 간 중계다.
|
|
@@ -7,12 +7,21 @@
|
|
|
7
7
|
|
|
8
8
|
### 1. 보안 기본값 = fail-closed (v0.15 §2.5.1)
|
|
9
9
|
|
|
10
|
-
**CORS · rate limit · CSRF
|
|
11
|
-
설정으로만 (v0.15 §2.5.1 원문 근거).
|
|
10
|
+
**CORS · rate limit · CSRF · 보안 응답 헤더는 코어에서 기본 켬.** 끄는 것은
|
|
11
|
+
명시적 설정으로만 (v0.15 §2.5.1 원문 근거).
|
|
12
12
|
|
|
13
13
|
보안 결함 = 보안이 선택 설치면 설치 안 한 앱의 기본값이 무방비가
|
|
14
14
|
된다. Rails/Laravel 이 증명한 원칙이다 (§2.5.1).
|
|
15
15
|
|
|
16
|
+
- **보안 응답 헤더 (결정 86)** — 코어가 모든 응답에 CSP·HSTS(HTTPS 한정)·
|
|
17
|
+
`X-Content-Type-Options: nosniff`·`X-Frame-Options: SAMEORIGIN`·
|
|
18
|
+
`Referrer-Policy: strict-origin-when-cross-origin`·`Cross-Origin-Opener-Policy`
|
|
19
|
+
를 자동으로 붙인다. 기본 CSP 는 Inertia SPA + vite 스택에서 안 깨지게 튜닝돼
|
|
20
|
+
있다(`script-src 'self'`·인라인 스타일 허용·`connect-src ... ws: wss:` 로
|
|
21
|
+
realtime 허용). 끄거나 조정은 `createApp({ security: { securityHeaders: … } })`
|
|
22
|
+
— `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
|
|
23
|
+
`helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
|
|
24
|
+
|
|
16
25
|
### 2. 세션·CSRF·JWT
|
|
17
26
|
|
|
18
27
|
- 세션은 앱별 완전 분리 (v0.15 §7 · Fastify 캡슐화 스코프): 쿠키
|
|
@@ -275,6 +275,11 @@ export default controller({
|
|
|
275
275
|
- **`@gaonjs/*` 스코프 직접 import 금지** — 파사드 `gaonjs/*` 만.
|
|
276
276
|
- **bigint PK 를 render props 로 흘릴 때는 `String(p.id)` 정규화**
|
|
277
277
|
(결정 37 · 상세는 `agents/frontend.md`).
|
|
278
|
+
- **정적 파일(robots.txt·favicon.ico·이미지 등)은 `apps/<앱>/static/`** 에 둔다
|
|
279
|
+
(결정 85) — 앱 prefix 아래로 서빙된다(web→`/robots.txt`, admin→`/admin/robots.txt`).
|
|
280
|
+
라우트·`/assets/*` 가 항상 우선하므로 라우트와 같은 경로에 두면 가려진다
|
|
281
|
+
(doctor **static-collision** 가 경고). 정적 서빙에 `@fastify/static` 을 따로
|
|
282
|
+
깔지 말 것(코어 내장).
|
|
278
283
|
|
|
279
284
|
## 관련 결정 번호
|
|
280
285
|
|
|
@@ -28,7 +28,9 @@ export interface HealthDatabase {
|
|
|
28
28
|
export interface HealthHub {
|
|
29
29
|
configured: boolean
|
|
30
30
|
connected?: boolean
|
|
31
|
+
// jobs: JetStream 스트림 대기 수 · channels: 채널별 실 접속자 수(결정 71).
|
|
31
32
|
streams?: { stream: string; waiting: number | null }[]
|
|
33
|
+
channels?: { channel: string; members: number }[] | null
|
|
32
34
|
error?: string
|
|
33
35
|
}
|
|
34
36
|
export interface HealthDoctor {
|
|
@@ -6,114 +6,39 @@
|
|
|
6
6
|
// 레이아웃은 shared 에 두지 않는다(E-5 §2.3) — 앱마다 레이아웃이 다른 것이
|
|
7
7
|
// 정상이고, 공용 조각(로고·푸터 등) 만 shared/components 로 뽑는다.
|
|
8
8
|
//
|
|
9
|
-
// 다크 헤더 + 라이트 본문(결정 69).
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// 헤더에 두려면 아래 nav 에 <a href="/session/new">로그인</a> 을 직접 추가한다.
|
|
9
|
+
// 다크 헤더 + 라이트 본문(결정 69). 스타일은 Tailwind 유틸(결정 74) — 다크
|
|
10
|
+
// 헤더는 본문 테마와 무관하게 항상 어둡게 두려고 브랜드 색을 명시값으로 박는다.
|
|
11
|
+
// 버전은 스캐폴드 시점 gaonjs 버전이 박힌다.
|
|
13
12
|
|
|
14
13
|
// package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
|
|
15
14
|
const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
|
|
16
15
|
</script>
|
|
17
16
|
|
|
18
17
|
<template>
|
|
19
|
-
<div class="
|
|
20
|
-
<header
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
<span
|
|
18
|
+
<div class="flex min-h-screen flex-col bg-background text-foreground">
|
|
19
|
+
<header
|
|
20
|
+
class="flex items-center justify-between gap-4 border-b border-[#21262d] bg-[#0d1117] px-6 py-3.5 text-[#e6edf3]"
|
|
21
|
+
>
|
|
22
|
+
<a href="/" class="inline-flex items-baseline gap-2.5 text-inherit no-underline">
|
|
23
|
+
<span
|
|
24
|
+
class="inline-block rounded-md bg-gradient-to-br from-[#4f8cff] to-[#7c5cff] px-1.5 py-0.5 text-xs font-bold tracking-wide text-white"
|
|
25
|
+
>가온</span>
|
|
26
|
+
<span class="text-[0.95rem] font-bold tracking-[0.08em]">GAONJS</span>
|
|
27
|
+
<span class="text-xs tabular-nums text-[#8b949e]">v{{ version }}</span>
|
|
25
28
|
</a>
|
|
26
|
-
<nav class="
|
|
27
|
-
<a href="https://gaonjs.dev" target="_blank" rel="noreferrer">문서</a>
|
|
28
|
-
<a href="https://github.com/gaonjs" target="_blank" rel="noreferrer">GitHub</a>
|
|
29
|
+
<nav class="flex gap-[1.1rem] text-sm">
|
|
30
|
+
<a href="https://gaonjs.dev" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">문서</a>
|
|
31
|
+
<a href="https://github.com/gaonjs" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">GitHub</a>
|
|
29
32
|
</nav>
|
|
30
33
|
</header>
|
|
31
34
|
|
|
32
|
-
<main class="
|
|
35
|
+
<main class="w-full flex-1">
|
|
33
36
|
<slot />
|
|
34
37
|
</main>
|
|
35
38
|
|
|
36
|
-
<footer class="
|
|
39
|
+
<footer class="border-t border-border px-6 py-5 text-center text-muted-foreground">
|
|
37
40
|
<small>{{PROJECT_NAME}} · Powered by
|
|
38
|
-
<a href="https://gaonjs.dev" target="_blank" rel="noreferrer">Gaon</a></small>
|
|
41
|
+
<a href="https://gaonjs.dev" target="_blank" rel="noreferrer" class="text-[#4f8cff] no-underline">Gaon</a></small>
|
|
39
42
|
</footer>
|
|
40
43
|
</div>
|
|
41
44
|
</template>
|
|
42
|
-
|
|
43
|
-
<style scoped>
|
|
44
|
-
.gaon-layout {
|
|
45
|
-
min-height: 100vh;
|
|
46
|
-
display: flex;
|
|
47
|
-
flex-direction: column;
|
|
48
|
-
background: #ffffff;
|
|
49
|
-
color: #1b1f24;
|
|
50
|
-
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/* 다크 헤더 — 본문 테마와 무관하게 브랜드 바는 어둡게. */
|
|
54
|
-
.gaon-header {
|
|
55
|
-
display: flex;
|
|
56
|
-
align-items: center;
|
|
57
|
-
justify-content: space-between;
|
|
58
|
-
gap: 1rem;
|
|
59
|
-
padding: 0.85rem 1.5rem;
|
|
60
|
-
background: #0d1117;
|
|
61
|
-
color: #e6edf3;
|
|
62
|
-
border-bottom: 1px solid #21262d;
|
|
63
|
-
}
|
|
64
|
-
.gaon-brand {
|
|
65
|
-
display: inline-flex;
|
|
66
|
-
align-items: baseline;
|
|
67
|
-
gap: 0.55rem;
|
|
68
|
-
text-decoration: none;
|
|
69
|
-
color: inherit;
|
|
70
|
-
}
|
|
71
|
-
.gaon-brand-mark {
|
|
72
|
-
display: inline-block;
|
|
73
|
-
padding: 0.15rem 0.45rem;
|
|
74
|
-
border-radius: 6px;
|
|
75
|
-
background: linear-gradient(135deg, #4f8cff, #7c5cff);
|
|
76
|
-
color: #fff;
|
|
77
|
-
font-weight: 700;
|
|
78
|
-
font-size: 0.8rem;
|
|
79
|
-
letter-spacing: 0.02em;
|
|
80
|
-
}
|
|
81
|
-
.gaon-brand-word {
|
|
82
|
-
font-weight: 700;
|
|
83
|
-
letter-spacing: 0.08em;
|
|
84
|
-
font-size: 0.95rem;
|
|
85
|
-
}
|
|
86
|
-
.gaon-brand-ver {
|
|
87
|
-
font-size: 0.72rem;
|
|
88
|
-
color: #8b949e;
|
|
89
|
-
font-variant-numeric: tabular-nums;
|
|
90
|
-
}
|
|
91
|
-
.gaon-nav {
|
|
92
|
-
display: flex;
|
|
93
|
-
gap: 1.1rem;
|
|
94
|
-
font-size: 0.85rem;
|
|
95
|
-
}
|
|
96
|
-
.gaon-nav a {
|
|
97
|
-
color: #c9d1d9;
|
|
98
|
-
text-decoration: none;
|
|
99
|
-
}
|
|
100
|
-
.gaon-nav a:hover {
|
|
101
|
-
color: #fff;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
.gaon-main {
|
|
105
|
-
flex: 1;
|
|
106
|
-
width: 100%;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
.gaon-footer {
|
|
110
|
-
padding: 1.25rem 1.5rem;
|
|
111
|
-
text-align: center;
|
|
112
|
-
color: #6a737d;
|
|
113
|
-
border-top: 1px solid #eaecef;
|
|
114
|
-
}
|
|
115
|
-
.gaon-footer a {
|
|
116
|
-
color: #4f8cff;
|
|
117
|
-
text-decoration: none;
|
|
118
|
-
}
|
|
119
|
-
</style>
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
// import.meta.glob 으로 만든다. 심볼 출처가 코드에 그대로 보인다.
|
|
10
10
|
import { createGaonApp } from 'gaonjs/vue'
|
|
11
11
|
|
|
12
|
+
// 전역 스타일 — Tailwind 레이어 + 디자인 토큰(결정 74). 부수효과 import 라
|
|
13
|
+
// 번들에 CSS 가 실린다. 앱마다 하나(관례 = 배치).
|
|
14
|
+
import './style.css'
|
|
15
|
+
|
|
12
16
|
// 페이지는 지연 로드(코드 스플리팅) — 큰 앱에서도 첫 페이지 로딩이 빠르다.
|
|
13
17
|
// eager 로 바꿔도 되지만, One Way 의 기본은 지연 로드다.
|
|
14
18
|
const pages = import.meta.glob('./pages/**/*.vue')
|