@gaonjs/cli 0.63.1 → 0.65.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.js +34 -18
- package/dist/commands/g.d.ts +1 -0
- package/dist/commands/g.js +11 -2
- package/dist/commands/gen.js +1 -1
- package/dist/dev.d.ts +13 -7
- package/dist/dev.js +30 -22
- package/dist/doctor/fixers/i18n-layout.d.ts +2 -2
- package/dist/doctor/fixers/i18n-layout.js +119 -25
- package/dist/doctor/fixers/index.js +7 -2
- package/dist/doctor/fixers/types.d.ts +17 -2
- package/dist/doctor/i18n-app-scope.d.ts +3 -0
- package/dist/doctor/i18n-app-scope.js +94 -65
- package/dist/doctor/i18n-layout.d.ts +1 -1
- package/dist/doctor/i18n-layout.js +157 -32
- package/dist/doctor/i18n-server-scope.d.ts +2 -0
- package/dist/doctor/i18n-server-scope.js +124 -0
- package/dist/doctor/locale-parity.js +29 -68
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +36 -10
- package/dist/i18n-config.d.ts +9 -9
- package/dist/i18n-config.js +8 -15
- package/dist/messages-gen.d.ts +10 -10
- package/dist/messages-gen.js +42 -54
- package/dist/scaffold/app.d.ts +10 -1
- package/dist/scaffold/app.js +12 -2
- package/dist/templates/project/AGENTS.md.tpl +7 -6
- 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 +185 -74
- 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 +24 -11
- package/dist/templates/project/{locales → apps/web/locales}/en/frontend.json.tpl +1 -0
- package/dist/templates/project/{locales → apps/web/locales}/ko/frontend.json.tpl +1 -0
- package/dist/templates/project/gaon.config.ts.tpl +6 -5
- package/package.json +7 -7
- package/dist/templates/project/apps/web/locales/en.json.tpl +0 -3
- package/dist/templates/project/apps/web/locales/ko.json.tpl +0 -3
- /package/dist/templates/project/{locales → domain/locales}/en/backend.json.tpl +0 -0
- /package/dist/templates/project/{locales → domain/locales}/ko/backend.json.tpl +0 -0
|
@@ -80,7 +80,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
80
80
|
혼용 금지 · doctor response-mixing).
|
|
81
81
|
8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`routes.manifest.ts`
|
|
82
82
|
(routes 축 = 타입 브리지 + api() 런타임 매니페스트 2파일 · 결정 127)·
|
|
83
|
-
`tables.d.ts`·`messages.d.ts`(3축 ·
|
|
83
|
+
`tables.d.ts`·`messages.d.ts`(3축 · 카탈로그 있을 때 · 결정 158)·`env.d.ts`
|
|
84
84
|
(`.env` 스캔 · 프론트 앱 · 결정 198) 는 `gaon check`/`gaon dev`/`gaon gen` 이
|
|
85
85
|
재생성한다.
|
|
86
86
|
|
|
@@ -113,7 +113,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
113
113
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
114
114
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
115
115
|
|
|
116
|
-
### 2.2 `gaon doctor` 검사
|
|
116
|
+
### 2.2 `gaon doctor` 검사 36종
|
|
117
117
|
|
|
118
118
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
119
119
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -141,15 +141,16 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
141
141
|
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon doctor --fix --yes` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
142
142
|
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). **파티션 키 컬럼이 실제 컬럼인지도 검사**(결정 277 · `checkPartitions` — 오타·유령 컬럼). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`·`checkPartitions`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134·277 · `agents/data.md`)
|
|
143
143
|
26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
|
|
144
|
-
27. `locale-parity` —
|
|
144
|
+
27. `locale-parity` — **같은 스코프 안에서 로케일 간** 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(서버) 또는 키 문자열(클라 · 결정 455)을 본다. 비교 단위 3종 — ① `domain/locales` backend ② 앱별 frontend ③ 앱별 backend. **스코프·소유자를 가로지르는 비교는 하지 않는다**(앱 전용 키가 전량 오탐). 복수형 접미사는 base 로 정규화해 비교한다. 로케일이 0·1개면 무소음 (`--json` 은 `detail.missing`·`detail.scope`·`detail.owner` · 결정 216·456·459 · `agents/i18n.md` §9.1)
|
|
145
145
|
28. `render-return` — 액션이 `this.render`/`this.redirect`/`this.json` 을 호출만 하고 `return` 하지 않음 = 응답이 버려져 조용히 204(백지) — `return this.render(...)` 로 고치라 (결정 340 · 경고)
|
|
146
146
|
29. `channel-collision` — 두 앱이 **같은 이름의 채널**을 각각 정의 = **에러**. 채널 이름은 전역이다(브로드캐스트 subject `gaon.chan.<이름>`·프레즌스 키에 앱 프리픽스 없음) — 한 앱의 broadcast 가 다른 앱 연결로 팬아웃되고 접속자 목록이 병합되며, 두 정의의 `authorize` 가 갈리면 공개 쪽 규칙으로 메시지가 샌다. 앱마다 이름을 분리하거나(클라이언트 `useChannel` 인자도 함께), **여러 앱이 한 방을 공유**하는 것이 의도면 정의를 `domain/channels/<이름>.ts` 하나에 두고 각 앱 채널 파일에서 재수출하라(재수출은 통과 · 정의 하나 = 인가 규칙 하나 · **등록 파일은 `apps/<앱>/channels/` 에 그대로**). `shared/channels/` 는 정본이 아니다 — authorize 가 DB 를 보면 domain 값 import 가 필요해 `shared-purity`(#6)와 성립 불가이고, `shared/` 는 props 로만 받는 순수 UI 영역이다(§1-3). 옛 배치가 남아 있으면 같은 규칙이 **경고**로 회수한다 — 잡·리스너의 동명 등록 throw(결정 271)와 같은 계열의 정적 검사 (결정 456 · `agents/realtime.md` §2.10)
|
|
147
147
|
30. `channel-instance-authorize` — `instance: true` 채널(파라미터화 채널 · 결정 440)에 `authorize` 가 없음 = **경고**. 인스턴스 채널은 임의 문자열 키로 무한 실행 인스턴스(`/gaon/ws/<이름>/<인스턴스>`)가 열리므로, authorize 가 없으면 누구나 아무 인스턴스에나 입장한다. 매치·스레드처럼 참가자가 정해진 채널이면 `authorize(ctx)` 에서 `ctx.instance` 로 입장을 판정하라 — 공개 관전형(누구나 입장)이 의도면 무시해도 된다(재수출 정의는 대상 모듈을 따라가 판정 · `agents/realtime.md` §2.7)
|
|
148
148
|
31. `agents-docs-stale` — 이 문서(`AGENTS.md`)·`agents/*.md` 사본이 **설치된 gaonjs 템플릿(정본)과 다름** = **경고**(byte 비교). gaonjs 업그레이드 후 관례 문서가 옛 채로 남으면 AI 가 낡은 관례·없는 표면으로 코드를 짠다 — `gaon g agents-docs` 로 재동기하라(미리보기 `--check` · 멱등 · 사본에 직접 적은 내용은 프로젝트 소유 문서(CLAUDE.md)로 옮긴 뒤 — 이 문서들은 프레임웍 정본 사본이다) (결정 449)
|
|
149
149
|
32. `dotenv-node-env` — 공유 `.env`(·`.env.local`·`.env.example`)에 `NODE_ENV` 가 설정됨 = **경고**. **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production(결정 430). 이 파일들은 개발·운영이 함께 읽으므로 값을 박으면 모드가 양쪽으로 샌다: `production` 이면 `gaon dev` 가 쿠키 Secure·dev 플레이스홀더 secret 거부로 죽고, `development` 면 운영 `gaon serve` 에서 프로덕션 안전장치(플레이스홀더 secret 거부·쿠키 Secure·락 in-memory 폴백 차단)가 통째로 꺼진다(**부팅은 green, 보안만 꺼짐**). `.env` 에서 그 줄을 지우고, 모드별 값이 필요하면 `.env.development`/`.env.production` 오버레이에, 일회성이면 명령 앞에 붙인다(`NODE_ENV=production gaon serve`) — 모드별 오버레이 파일은 검사 대상이 아니다 (결정 430)
|
|
150
150
|
33. `page-fetch` — 세션 앱 `.vue` 가 앱 내부 경로를 raw `fetch('/...')` 로 호출 = **경고**. CSRF 토큰 자동 부착(결정 166·341·342)은 `api()`·`useForm`·`router` 를 탈 때만 작동해, raw fetch 의 상태 변경 요청(POST/PUT/PATCH/DELETE)은 403 으로 죽는다(rooms 샘플 실사용에서 강퇴/위임이 이 경로로 죽었다). JSON 액션(강퇴·위임·좋아요 등 커맨드 포함)은 `api()`, 폼은 `useForm`, 부득이한 커스텀 전송은 `readCsrfToken()` 탈출구 — 첫 인자가 `/` 로 시작하는 문자열/템플릿 리터럴만 검출(외부 URL·변수 인자 오탐 제외), JWT/API 앱은 REST + fetch 가 정본이라 제외 (결정 453 · `agents/frontend.md` §2)
|
|
151
|
-
34. `i18n-layout` —
|
|
152
|
-
35. `i18n-app-scope` — 앱 코드가 **그 앱 카탈로그에 없는** 클라 `t()` 키를
|
|
151
|
+
34. `i18n-layout` — **옛 카탈로그 배치**가 남아 있음 = **경고**(폐지된 루트 `locales/`·앱 평면 `<로케일>.json`) · **오류**(`domain/locales/<로케일>/frontend.json`·제거된 `i18n.dir` 설정). 결정 459 의 정본 배치는 소유자 2개다 — `domain/locales/<로케일>/backend.json`(도메인 공통 서버 문구) · `apps/<앱>/locales/<로케일>/{frontend,backend}.json`(그 앱 화면·서버 문구). 옛 배치를 그냥 두면 **아무도 읽지 않는 폴더**가 되어 전 화면·전 메일이 키 문자열로 degrade 하므로 조용히 두지 않는다. `gaon doctor --fix` 가 이관한다(루트 backend·레거시 단일 파일 → `domain/locales`, 앱 평면 파일 → 그 앱 `frontend.json`, 루트 공용 frontend → **전 앱 복제 후 원본 제거**). 전 앱 복제는 되돌리기 쉬운 방향이라 자동화하고, 잉여 키 정리·도메인 frontend 재배치는 사람이 판단한다 (결정 454·459 · `agents/i18n.md`)
|
|
152
|
+
35. `i18n-app-scope` — 앱 코드가 **그 앱 카탈로그에 없는** 클라 `t()` 키를 참조, 또는 `shared/` 컴포넌트가 쓰는 키가 **일부 앱에만** 존재 = **오류**. 클라 키 유니온은 프로젝트 전체 frontend 합집합이라(두 앱이 같은 `GaonMessages.keys` 를 다른 유니온으로 augment 하면 TS2717 이고 `gaon check` 는 단일 tsc 프로그램이다 — 앱별 유니온이 구조적으로 불가) 타입만으로는 앱 경계를 못 지킨다. 앱 번들에는 그 앱 카탈로그만 실리므로 다른 앱 전용 키는 런타임에 번역 대신 **키 문자열이 그대로** 보인다. `shared/` 는 어느 앱 번들에도 실릴 수 있어 그 키는 **모든 앱**에 있어야 한다(공용 카탈로그는 폐지됐다 — 결정 459 O1 · 복제 + 이 검사로 강제). 문구는 `apps/<앱>/locales/<로케일>/frontend.json` 에 두고 `gaon gen`. 서버 `t()`(`gaonjs/i18n`) 호출은 `i18n-server-scope` 담당이다 (결정 454·459)
|
|
153
|
+
36. `i18n-server-scope` — 서버 `t()`(`gaonjs/i18n`)가 **소유자 경계**를 넘는 키를 참조 = **오류**. `domain/**` 은 `domain/locales` backend 만, `apps/<앱>/**` 은 그 앱 backend ∪ frontend ∪ domain backend 만 볼 수 있다. 이 유형은 두 층이 모두 놓친다 — 서버 키 유니온은 domain ∪ 전 앱이라 **컴파일을 통과**하고(결정 459 O5), 요청 컨텍스트에서는 앱 네임스페이스가 상속돼 **우연히 해석된다**. 같은 코드가 워커(`gaon work`)·크론에서 불리면 앱 스코프가 없어 키가 비므로 "개발 중엔 되는데 잡에서만 키 문자열이 뜬다" 로 샌다. 결정 455 의 2단 계약(빌드=차단 / 런타임=키 렌더+경고)에서 **빌드=차단의 절반이 무너지는** 지점이라 정적으로 못박는다. 수리: 문구를 `domain/locales/<로케일>/backend.json` 으로 올리거나 그 호출을 도메인 밖으로 옮긴다 (결정 459 · `agents/i18n.md` §3)
|
|
153
154
|
|
|
154
155
|
## 3. 로직 배치 One Way 판단표
|
|
155
156
|
|
|
@@ -210,7 +211,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
210
211
|
```bash
|
|
211
212
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
|
|
212
213
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
213
|
-
gaon doctor # 정적 검사
|
|
214
|
+
gaon doctor # 정적 검사 36종 (§2.2)
|
|
214
215
|
```
|
|
215
216
|
|
|
216
217
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -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
|
|