@gaonjs/cli 0.55.0 → 0.56.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/test.js +28 -4
- package/dist/doctor/auth-wiring.js +5 -2
- package/dist/doctor/channel-collision.d.ts +9 -0
- package/dist/doctor/channel-collision.js +119 -0
- package/dist/doctor/fixers/index.d.ts +1 -1
- package/dist/doctor/fixers/index.js +6 -1
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +10 -2
- package/dist/doctor.js +45 -3
- package/dist/generate.d.ts +5 -0
- package/dist/generate.js +11 -3
- package/dist/index.js +7 -5
- package/dist/scaffold/controller.js +3 -1
- package/dist/scaffold/page.js +6 -4
- package/dist/templates/project/AGENTS.md.tpl +9 -5
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/async.md.tpl +20 -7
- package/dist/templates/project/agents/data.md.tpl +65 -11
- package/dist/templates/project/agents/frontend.md.tpl +19 -9
- package/dist/templates/project/agents/i18n.md.tpl +17 -4
- package/dist/templates/project/agents/realtime.md.tpl +105 -15
- package/dist/templates/project/agents/seal.md.tpl +10 -3
- package/dist/templates/project/agents/security.md.tpl +13 -1
- package/dist/templates/project/agents/storage.md.tpl +21 -9
- package/dist/templates/project/agents/testing.md.tpl +58 -0
- package/dist/templates/project/agents/web.md.tpl +157 -15
- package/package.json +6 -6
|
@@ -39,6 +39,11 @@ await SendWelcomeMail.later(user.id) // domain/jobs/sendWelcomeMail.ts (§1)
|
|
|
39
39
|
|
|
40
40
|
```ts
|
|
41
41
|
// 파생 효과 — 커밋 뒤에만 나가야 하는 발행은 서비스 afterCommit (§4 아웃박스).
|
|
42
|
+
// domain/services/registerUser.ts
|
|
43
|
+
import { service, afterCommit } from 'gaonjs/service' // service·afterCommit 는 같은 서브패스
|
|
44
|
+
import { User } from '../models/User.js'
|
|
45
|
+
import { ResizeAvatar } from '../jobs/resizeAvatar.js'
|
|
46
|
+
|
|
42
47
|
export const RegisterUser = service(async (input: RegisterInput) => {
|
|
43
48
|
const user = await User.create(input)
|
|
44
49
|
afterCommit(() => ResizeAvatar.later(user.id)) // 커밋 성공 후에만 발행
|
|
@@ -89,18 +94,25 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
|
|
|
89
94
|
유추가 충돌한다. 이제 **등록 시 throw**(조용한 덮어쓰기 = 발행이 엉뚱한
|
|
90
95
|
핸들러로 가던 무신호 버그 봉합) — 각각 `name` 을 다르게 주거나 파일을 나눈다.
|
|
91
96
|
리스너(`on()`)도 동형 — 같은 파일에 여럿 두면 `id` 를 다르게 준다(durable 충돌).
|
|
92
|
-
-
|
|
93
|
-
`
|
|
97
|
+
- **옵션(`JobOptions` 전부)** — `name` · `queue`(기본 `'default'`) · `retries`(기본 3) ·
|
|
98
|
+
`concurrency`(기본 1) + 백오프(`curve` 곡선 ms · `jitter` 기본 0.2). 이 6개가 전부다 —
|
|
99
|
+
**`maxDeliver` 는 잡 옵션이 아니라 워커 옵션**이다(아래).
|
|
94
100
|
- **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
|
|
95
101
|
`gaon jobs retry <id>` 로 조회·재적재한다(조회는 전량 배치 스캔 — 옛 레코드도
|
|
96
102
|
상한 없이 찾아 재적재할 수 있다 · 결정 351).
|
|
97
103
|
- **네이티브 재전달 소진도 DLQ 로 간다(결정 347).** 크래시 루프·미등록 잡(워커에
|
|
98
|
-
`domain/jobs/` 파일이 배포되지 않음)이 재전달 상한(기본 25
|
|
104
|
+
`domain/jobs/` 파일이 배포되지 않음)이 재전달 상한(기본 25)을
|
|
99
105
|
소진하면, 워커가 MAX_DELIVERIES advisory 를 받아 그 잡을 DLQ 로 이관한다 —
|
|
100
106
|
이전엔 스트림에 무신호로 영구 잔류했다. 미등록 잡의 재전달 지연은 지수
|
|
101
107
|
(1s→2s→…30s 포화)이고 잡 이름당 1회 경고를 남긴다(정상 롤링 배포 창은 통과).
|
|
102
108
|
advisory 는 비영속이라 백스톱은 best-effort 다(소진 순간 워커가 전무하면 다음
|
|
103
109
|
소진 때 회수).
|
|
110
|
+
- **`maxDeliver` 는 워커 옵션이다 — 잡별로 못 준다.** 재전달 상한은 `runWork()` 의
|
|
111
|
+
`maxDeliver`(= `runWorker()` 로 전달 · `packages/async/src/worker.ts`)이고 그 워커가
|
|
112
|
+
소비하는 **모든 큐에 공통**으로 걸린다. `job(handler, { maxDeliver: … })` 같은 표면은
|
|
113
|
+
없다(`JobOptions` 는 위 6개뿐 — 넘겨도 무시된다). 잡별로 조절 가능한 것은
|
|
114
|
+
`retries`(앱 레벨 재시도)뿐이고, `maxDeliver` 는 그 아래층인 **JetStream 네이티브
|
|
115
|
+
재전달**(크래시 복구·미등록 잡 백스톱)의 상한이라 축이 다르다.
|
|
104
116
|
- **큐 동시성은 큐별로 정확히 적용된다(결정 348)** — 다른 큐의 긴 잡이 이 큐의
|
|
105
117
|
처리량을 깎지 않는다(잡별 `concurrency` 선언 = 그 큐의 실제 동시 처리 수).
|
|
106
118
|
- **워커 복원력(결정 258)** — 재시도 재적재나 DLQ 이관을 하는 도중 NATS 가
|
|
@@ -190,7 +202,8 @@ await OrderPlaced.emit({ orderId: 1n })
|
|
|
190
202
|
|
|
191
203
|
- 실패하면 백오프(잡과 같은 곡선 `[1s, 5s, 30s, 5m, 1h]`)로 재전달되고, 최대
|
|
192
204
|
재전달(기본 6 · 최초 포함) 소진 시 **영구 폐기**된다. 즉 계속 실패하는 이벤트는
|
|
193
|
-
|
|
205
|
+
최초 실패로부터 **약 1시간 5분**(1s+5s+30s+5m+1h = **1h05m36s** · 지터 ±20% 별도)
|
|
206
|
+
뒤 사라진다 — `gaon work` 가 `✗ 이벤트 폐기` 로 신호한다
|
|
194
207
|
(결정 308 · 이전엔 human 모드 무신호). **크래시 루프**(핸들러 throw 가 아니라
|
|
195
208
|
프로세스가 ack 전에 반복 사망)로 소진돼도 MAX_DELIVERIES advisory 백스톱이
|
|
196
209
|
같은 `✗ 이벤트 폐기` 신호를 낸다(결정 398 · 잡의 결정 347 동형 · advisory 는
|
|
@@ -476,7 +489,7 @@ async create() {
|
|
|
476
489
|
- **스케줄 대상은 항상 잡 · 무인자** — `s.every('10m', async () => ...)` 인라인
|
|
477
490
|
함수 금지. 인자 필수 잡은 컴파일 에러로 거부된다(결정 310 · §5).
|
|
478
491
|
- **계속 실패하는 리스너는 이벤트를 잃는다** — 리스너는 DLQ 가 없어 재전달
|
|
479
|
-
소진(기본 6회 · 약
|
|
492
|
+
소진(기본 6회 · 약 1h05m36s) 후 영구 폐기된다(§3 계약 · `gaon work` 가 `✗ 이벤트
|
|
480
493
|
폐기` 로 신호). 유실 불가 처리는 리스너에서 잡으로 넘긴다.
|
|
481
494
|
- **커밋 전 발행 주의** — 트랜잭션 안에서 DB 확정 후에만 나가야 하는
|
|
482
495
|
발행은 `afterCommit()` 또는 아웃박스로.
|
|
@@ -508,11 +521,11 @@ async create() {
|
|
|
508
521
|
| 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
|
|
509
522
|
| 결정 258 | 워커 소비 루프 복원력(§1) — 재시도/DLQ 발행이 NATS 순단으로 실패해도 큐 소비가 멈추지 않음(nak 재전달 백스톱 · 잡 유실 방지 · 실패 로그) |
|
|
510
523
|
| 결정 306 | 아웃박스 발행 실패 행 격리(§4) — poison 행(페이로드 상한 초과 등)이 배치 전체를 세우지 않음 · 실패 행은 미발행 유지(유실 없음·재시도) · `relay-error` 이벤트로 관측 |
|
|
511
|
-
| 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ
|
|
524
|
+
| 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ 없음 · 곡선 합 **1h05m36s** · 종전 "1h36m" 오기 정정) 명문화 |
|
|
512
525
|
| 결정 310 | 스케줄 대상 잡 무인자 가드(§5) — 인자 필수 잡 등록을 컴파일 타임 거부(메서드 bivariance 로 통과해 `undefined` 인자 발화하던 구멍 차단) |
|
|
513
526
|
| 결정 312 | 아웃박스 릴레이 env 튜닝(§4) — `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`(`GAON_WORKER_*` 대칭) |
|
|
514
527
|
| 결정 346 | 아웃박스 2단계 publish(§4) — claim(`claimed_at`+SKIP LOCKED 짧은 tx) → 커밋 → tx 밖 publish → 표시 · NATS 지연의 DB 락 전파 제거 · claim 리스 60s(< dedupe 창) · 유실 0 |
|
|
515
|
-
| 결정 347 | max_deliver 소진 DLQ 백스톱(§1) — MAX_DELIVERIES advisory → DLQ 이관(무신호 영구 잔류 봉합) · 미등록 잡 지수 nak(1s→30s 포화)+이름당 1회 경고 · `maxDeliver` 옵션 |
|
|
528
|
+
| 결정 347 | max_deliver 소진 DLQ 백스톱(§1) — MAX_DELIVERIES advisory → DLQ 이관(무신호 영구 잔류 봉합) · 미등록 잡 지수 nak(1s→30s 포화)+이름당 1회 경고 · 상한은 **워커 옵션** `runWork({ maxDeliver })`(기본 25 · 잡 옵션 아님 · 워커의 전 큐 공통) |
|
|
516
529
|
| 결정 348 | 워커 큐별 동시성 게이트(§1) — 전역 inflight 비교가 낳던 교차 큐 간섭 제거(선언 `concurrency` = 실제 동시 처리) |
|
|
517
530
|
| 결정 349 | 리스 갱신 순단 재시도 + every 위상 KV 보존(§5) — 키가 내 것이면 revision 동기화 재시도 후에만 revoke · `gaon_scheduler` KV 로 위상 이어받기(플래핑 기아 봉합) |
|
|
518
531
|
| 결정 351 | DLQ 조회 배치 스캔(§1) — ordered 컨슈머 fetch 로 삭제 갭 서버 스킵 · findDlq 1000건 상한 제거(옛 레코드 retry 복원) |
|
|
@@ -166,6 +166,7 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
166
166
|
export const logs = table('logs', {
|
|
167
167
|
userId: t.belongsTo('users'),
|
|
168
168
|
status: t.enum(['active', 'archived'] as const),
|
|
169
|
+
retries: t.int(),
|
|
169
170
|
createdAt: t.datetime(),
|
|
170
171
|
meta: t.jsonb<Record<string, unknown>>().index(), // 자동 gin
|
|
171
172
|
...t.timestamps(),
|
|
@@ -179,11 +180,14 @@ export const logs = table('logs', {
|
|
|
179
180
|
{ cols: ['status'], where: "status = 'active'" }, // partial
|
|
180
181
|
{ expr: "(meta->>'tenant')" }, // 표현식 인덱스(이름 자동 · 해시)
|
|
181
182
|
],
|
|
182
|
-
check: [['
|
|
183
|
+
check: [['retries_non_negative', 'retries >= 0']], // [name, expr]
|
|
183
184
|
})
|
|
184
185
|
```
|
|
185
186
|
|
|
186
187
|
- 인덱스 원소가 **문자열 배열**이면 기존과 100% 동일(btree). **객체**면 `cols`·`expr`·`using`·`where`·`name`.
|
|
188
|
+
- `cols` 는 **선언한 컬럼명**을 그대로 쓰지만 `where`·`expr`·`check` 는 **raw SQL** 이다 —
|
|
189
|
+
camelCase 컬럼을 raw SQL 에서 참조할 때는 큰따옴표로 감싼다(`"createdAt" > now()`).
|
|
190
|
+
Postgres 는 따옴표 없는 식별자를 소문자로 접어 `createdat` 을 찾는다.
|
|
187
191
|
- 이름 규약 `idx_<table>_<cols>` 는 method/partial 을 구분하지 않는다 — **같은 컬럼에 두 인덱스**
|
|
188
192
|
(예: 컬럼 `.index({where})` + 테이블 레벨 `[['col']]`)를 선언하면 이름이 충돌해 **정의 시점에
|
|
189
193
|
throw** 한다(결정 329). 테이블 레벨 객체의 `name` 으로 구분한다.
|
|
@@ -193,7 +197,7 @@ export const logs = table('logs', {
|
|
|
193
197
|
안 잡혀 매 diff 마다 재생성 대상이 되므로 **정의 시점에 throw** 한다(결정 373). 인덱스
|
|
194
198
|
이름은 **커넥션(스키마) 전역 유일**이라 서로 다른 테이블의 같은 명시 name 도 diff 진입에서
|
|
195
199
|
throw 한다(결정 382).
|
|
196
|
-
- **MySQL/MariaDB(legacy §
|
|
200
|
+
- **MySQL/MariaDB(legacy · `adapter: 'mysql'` · §7) 커넥션은 gin/brin/gist·partial·표현식 인덱스가 없다** — 선언하면
|
|
197
201
|
`gaon db migrate` 가 **명확히 실패**한다(조용히 btree 로 떨구지 않음). 이런 인덱스는 main(postgres)에.
|
|
198
202
|
|
|
199
203
|
**선언적 파티셔닝** (결정 277 · PostgreSQL · 대용량 로그/이벤트/감사):
|
|
@@ -211,10 +215,24 @@ export const logs = table('logs', {
|
|
|
211
215
|
- 전략은 `range | list | hash`. **부모 테이블만 선언**한다 — 파티션 키는 PK 에 자동 편입된다
|
|
212
216
|
(복합 PK `(id, createdAt)`). 개별 **자식 파티션은 스키마 밖**(시간에 따라 증식)이라 헬퍼로 관리한다:
|
|
213
217
|
```ts
|
|
214
|
-
// domain/
|
|
215
|
-
import {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
// domain/jobs/rollLogPartitions.ts — 파티션 롤링은 잡으로 두고 스케줄에 얹는다(자동 마법 없음).
|
|
219
|
+
import { job } from 'gaonjs/async'
|
|
220
|
+
import { getConnection, rollMonthlyPartitions } from 'gaonjs/data'
|
|
221
|
+
|
|
222
|
+
export const RollLogPartitions = job(async () => {
|
|
223
|
+
// getConnection('키') 가 Kysely 인스턴스 — 인자 생략 = main(§7).
|
|
224
|
+
await rollMonthlyPartitions(getConnection(), { table: 'logs', ahead: 1, keep: 6 })
|
|
225
|
+
// ahead=다가올 개월 미리 생성 · keep=6 이면 6개월 지난 파티션 파기(keep 없으면 파기 안 함)
|
|
226
|
+
})
|
|
227
|
+
```
|
|
228
|
+
```ts
|
|
229
|
+
// domain/schedule.ts — 스케줄 대상은 항상 잡(무인자) · 리더 하나만 발화한다(agents/async.md §5).
|
|
230
|
+
import { schedule } from 'gaonjs/async'
|
|
231
|
+
import { RollLogPartitions } from './jobs/rollLogPartitions.js'
|
|
232
|
+
|
|
233
|
+
export default schedule((s) => {
|
|
234
|
+
s.daily.at('03:30', RollLogPartitions)
|
|
235
|
+
})
|
|
218
236
|
```
|
|
219
237
|
저수준 헬퍼: `createRangePartition`·`createListPartition`·`createHashPartition`·`createDefaultPartition`·
|
|
220
238
|
`dropPartition`·`listPartitions`. **retention(오래된 파티션 파기)은 절대 자동으로 하지 않는다** —
|
|
@@ -417,8 +435,36 @@ const rows = await Post.query()
|
|
|
417
435
|
|
|
418
436
|
혼동 유발이라 이름 분리를 유지한다 (E-4 (i) 결정).
|
|
419
437
|
|
|
420
|
-
### 7. 멀티 DB 커넥션
|
|
438
|
+
### 7. 멀티 DB 커넥션
|
|
439
|
+
|
|
440
|
+
커넥션은 루트 `gaon.config.ts` 의 **고유 키**로 선언하고, 스키마가 `{ db: '키' }`
|
|
441
|
+
로 그 커넥션에 붙는다(생략 = `main`).
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
// gaon.config.ts — 세 어댑터를 나란히 선언한 모습(SQL 둘 + 문서형 하나).
|
|
445
|
+
import { defineConfig } from 'gaonjs/config'
|
|
446
|
+
import { env } from 'gaonjs/env'
|
|
421
447
|
|
|
448
|
+
export default defineConfig({
|
|
449
|
+
db: {
|
|
450
|
+
main: { adapter: 'postgres', url: env('DATABASE_URL') }, // 키 생략 시 붙는 기본 커넥션
|
|
451
|
+
legacy: { adapter: 'mysql', url: env('LEGACY_URL') }, // MySQL · MariaDB 공통
|
|
452
|
+
logs: { adapter: 'mongodb', url: env('MONGO_URL') }, // 문서형 — collection() (§7.1)
|
|
453
|
+
},
|
|
454
|
+
})
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
- **`adapter` 는 리터럴 3종뿐** — `'postgres' | 'mysql' | 'mongodb'`. **MariaDB 는
|
|
458
|
+
`adapter: 'mysql'`** 로 선언한다(`'mariadb'` 라는 값은 없다 — 같은 와이어 프로토콜·같은
|
|
459
|
+
드라이버를 쓴다). 그래서 이 문서가 "MySQL/MariaDB(legacy)" 라고 부르는 방언 차이는 전부
|
|
460
|
+
`adapter: 'mysql'` 커넥션 이야기다.
|
|
461
|
+
- **URL 형식** — postgres `postgres://user:pass@host:5432/db` · MySQL/MariaDB
|
|
462
|
+
`mysql://user:pass@host:3306/db`(`mariadb://` 스킴도 같은 어댑터로 해석된다) ·
|
|
463
|
+
mongodb `mongodb://host:27017/db`(**DB 명 필수** · §7.1). `url` 대신
|
|
464
|
+
`host`·`port`·`user`·`password`·`database` 를 따로 줘도 되고, `poolMax` 로 풀 상한을 준다.
|
|
465
|
+
- 스캐폴드 기본은 `main` 하나이며 env 미설정이면 배선하지 않는 삼항 형태다 — 커넥션을
|
|
466
|
+
늘릴 때는 **참 분기 안에** 키를 추가한다(doctor 의 커넥션·관계 검사가 참 분기의 키를
|
|
467
|
+
정적으로 읽는다 · 결정 135).
|
|
422
468
|
- **키 생략 = main** — 기본 경로는 단일 DB 프로젝트와 완전히 같다.
|
|
423
469
|
- **커넥션을 가로지르는 `belongsTo`·역방향 관계는 금지** — SQL 조인은
|
|
424
470
|
커넥션을 못 넘는다. doctor 의 **schema-relations** 검사(결정 134)가
|
|
@@ -478,12 +524,20 @@ SQL `model()`(Kysely) 옆에 문서형 동사 `collection()` 을 **Mongoose**
|
|
|
478
524
|
로그·이벤트·감사·분석처럼 **문서·유연 스키마·대량 append** 용도다. **`model()` 은
|
|
479
525
|
SQL 전용 · `collection()` 은 문서형** — 한 동사가 두 세계를 처리하지 않는다(The One Way).
|
|
480
526
|
|
|
481
|
-
|
|
527
|
+
**추가로 설치할 것은 `mongoose` 하나뿐이다.** `@gaonjs/adapter-mongo` 는 파사드
|
|
528
|
+
`gaonjs` 의 정규 의존이라 이미 설치돼 있고(그래서 `gaonjs/data` 가 `collection`·
|
|
529
|
+
`mongoSchema` 를 재수출한다), 실제 드라이버인 `mongoose` 만 **optional peer** 라
|
|
530
|
+
SQL 전용 프로젝트는 받지 않는다 — 문서형을 쓰는 프로젝트만 명시 설치한다:
|
|
482
531
|
|
|
483
532
|
```bash
|
|
484
|
-
npm i
|
|
533
|
+
npm i mongoose
|
|
485
534
|
```
|
|
486
535
|
|
|
536
|
+
> `@gaonjs/adapter-mongo` 를 앱 의존으로 **따로 적지 말 것** — 파사드가 exact-pin 으로
|
|
537
|
+
> 끌고 오는 버전과 앱이 적은 range 가 갈리면 설치 트리에 어댑터가 둘로 갈라져
|
|
538
|
+
> (레지스트리 split) 커넥션 레지스트리가 서로 안 보인다. 설치 명령은 프로젝트의
|
|
539
|
+
> 패키지 매니저를 따른다(`pnpm add mongoose`·`yarn add mongoose` 동형).
|
|
540
|
+
|
|
487
541
|
```ts
|
|
488
542
|
// domain/schema/auditLog.ts — mongoSchema() 는 진짜 Mongoose Schema 를 반환한다.
|
|
489
543
|
// 정본 패턴(결정 288·332): Doc + Methods + Model 인터페이스를 선언하고 제네릭 3개를
|
|
@@ -950,7 +1004,7 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
950
1004
|
belongsTo 를 떼도 컬럼 shape(bigint)가 같아 감지되지 않는다(FK 가 조용히 미생성/잔존).
|
|
951
1005
|
기존 컬럼에 FK 를 걸려면 손작성 마이그로 `ADD CONSTRAINT … FOREIGN KEY` 를 쓴다.
|
|
952
1006
|
이 제약·기본값 diff 는 postgres 커넥션 기준이다
|
|
953
|
-
(mysql=legacy §
|
|
1007
|
+
(mysql=legacy §7 은 aux introspect 미지원 → 이 diff 생략). legacy(mysql/mariadb)
|
|
954
1008
|
introspection 은 `char(n)`→uuid·`longtext`→jsonb **휴리스틱 매핑**을 쓴다(MariaDB 가
|
|
955
1009
|
uuid/json 을 그 물리 타입으로 저장하는 왕복 정합 · 결정 273 Bug B) — Gaon 스키마가 만든
|
|
956
1010
|
DB 에선 정확하지만, **기존(외부) DB 의 진짜 char/longtext 컬럼**은 uuid/jsonb 로 오인돼
|
|
@@ -964,7 +1018,7 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
964
1018
|
|
|
965
1019
|
- **시그니처**: `seed(fn: () => Promise<void> | void): SeedDef` — `gaonjs/data`
|
|
966
1020
|
에서 import 한다. 본문(`fn`)은 **모델을 그대로** 쓴다 — 모델이 커넥션을 자동
|
|
967
|
-
바인딩하므로(§
|
|
1021
|
+
바인딩하므로(§7) 시드는 커넥션을 몰라도 된다. `gaon db seed` 는 선언된
|
|
968
1022
|
**전 SQL 커넥션을 등록하고 시드를 1회 실행**한다(결정 367 — 여러 커넥션의
|
|
969
1023
|
모델을 한 시드에서 섞어 써도 된다 · 문서형(mongodb) 커넥션은 열지 않는다).
|
|
970
1024
|
- **멱등하게 짠다** — 시드는 재적재에 자주 쓰이므로 여러 번 돌려도 안전해야
|
|
@@ -106,7 +106,8 @@ const props = pageProps<'web:posts#index'>()
|
|
|
106
106
|
import { api } from 'gaonjs/vue' // 파사드 · 서브패스 X
|
|
107
107
|
|
|
108
108
|
async function search(q: string) {
|
|
109
|
-
// 첫 인자 = 'controller
|
|
109
|
+
// 첫 인자 = 라우트 키 '<app>:<controller>#<action>' — **앱 접두 필수**(pageProps 와 같은 키 · 결정 55).
|
|
110
|
+
// 두 번째 = 라우트 파라미터 + 쿼리/바디.
|
|
110
111
|
const { results } = await api('web:posts#search', { q })
|
|
111
112
|
return results
|
|
112
113
|
}
|
|
@@ -116,6 +117,11 @@ async function search(q: string) {
|
|
|
116
117
|
- **시그니처** — `api(key, params?, opts?)`. 제네릭 타입 인자를 직접
|
|
117
118
|
붙이지 않는다 — `key` 값 자체가 `keyof GaonRouteMap` 으로 좁혀져
|
|
118
119
|
반환 타입을 결정한다 (`packages/vue/src/api.ts`).
|
|
120
|
+
- **라우트 키에 앱 접두를 빠뜨리지 않는다** — `'posts#search'`(✗) 가 아니라
|
|
121
|
+
`'web:posts#search'`(○)다. 비-web 앱은 그 앱 이름을 쓴다(`'admin:posts#search'`).
|
|
122
|
+
**URL 프리픽스는 손으로 붙이지 않는다** — 매니페스트(`.gaon/routes.manifest.ts` ·
|
|
123
|
+
결정 127)가 앱 프리픽스를 포함한 최종 URL(`/admin/posts`)을 들고 있어 api() 가
|
|
124
|
+
그대로 친다. 접두를 빠뜨리면 키가 라우트 맵에 없어 **컴파일 에러**다(런타임 404 아님).
|
|
119
125
|
- **params** — 라우트에 `:id` 같은 자리표시자가 있으면 거기서 채우고,
|
|
120
126
|
남는 값은 GET 이면 쿼리스트링, 그 외 메서드는 JSON 본문으로 실린다
|
|
121
127
|
(서버 `this.params` 우선순위와 대칭 · `agents/web.md` §3).
|
|
@@ -357,7 +363,7 @@ import PageShell from '@shared/components/ui/PageShell.vue'
|
|
|
357
363
|
```vue
|
|
358
364
|
<script setup lang="ts">
|
|
359
365
|
import Pagination from '@shared/components/ui/Pagination.vue'
|
|
360
|
-
import { router } from 'gaonjs/vue'
|
|
366
|
+
import { pageProps, router } from 'gaonjs/vue'
|
|
361
367
|
const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
|
|
362
368
|
function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
|
|
363
369
|
</script>
|
|
@@ -414,22 +420,24 @@ if (env.dev) console.log(env.mode) // 내장: dev·prod·mode·baseUrl(camelCa
|
|
|
414
420
|
## 정본 예시
|
|
415
421
|
|
|
416
422
|
```vue
|
|
417
|
-
<!-- apps/web/pages/Posts/Index.vue — pageProps + api + string id key -->
|
|
423
|
+
<!-- apps/web/pages/Posts/Index.vue — Head + pageProps + api + string id key -->
|
|
418
424
|
<script setup lang="ts">
|
|
419
425
|
import { ref } from 'vue'
|
|
420
|
-
import { pageProps, api } from 'gaonjs/vue'
|
|
426
|
+
import { Head, pageProps, api } from 'gaonjs/vue'
|
|
421
427
|
import PostCard from '../../components/PostCard.vue'
|
|
422
428
|
|
|
423
429
|
const props = pageProps<'web:posts#index'>()
|
|
424
430
|
const results = ref<Awaited<ReturnType<typeof runSearch>>>([])
|
|
425
431
|
|
|
426
432
|
async function runSearch(q: string) {
|
|
427
|
-
const res = await api('web:posts#search', { q })
|
|
433
|
+
const res = await api('web:posts#search', { q }) // 라우트 키는 '<app>:<ctrl>#<action>'
|
|
428
434
|
return res.results
|
|
429
435
|
}
|
|
430
436
|
</script>
|
|
431
437
|
|
|
432
438
|
<template>
|
|
439
|
+
<!-- 문서 <title> 은 Head 로만 — document.title 수동 조작 금지(결정 271 · §1) -->
|
|
440
|
+
<Head title="글 목록" />
|
|
433
441
|
<div>
|
|
434
442
|
<!-- 컨트롤러가 String(p.id) 정규화 → :key 에 그대로 (결정 37) -->
|
|
435
443
|
<PostCard v-for="post in props.posts" :key="post.id" :title="post.title" />
|
|
@@ -471,12 +479,14 @@ async function runSearch(q: string) {
|
|
|
471
479
|
URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클·**자동 재연결**을
|
|
472
480
|
재구현하다 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에. 소켓이 끊기면
|
|
473
481
|
useChannel 이 지수 백오프로 **자동 재접속**하고 프레즌스를 재동기한다(결정 128 · 기본
|
|
474
|
-
켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 ·
|
|
475
|
-
재연결 안 함). 손 WebSocket 재연결
|
|
482
|
+
켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 · 종단 close code
|
|
483
|
+
**4401**(authorize 거부)·**4500**(seal 개봉 실패)만 재연결 안 함). 손 WebSocket 재연결
|
|
484
|
+
루프를 짜지 말 것. 결정 303: `send()` 는 소켓이
|
|
476
485
|
OPEN 이 아니면 보내지 않고 `false` 를 반환한다(큐잉 없음 — 유실 불가 송신은 반환값
|
|
477
486
|
확인). 컴포넌트 **밖**에서 부르면 즉시 접속되지만 자동 정리가 없어 호출자가
|
|
478
487
|
`close()` 를 책임진다(기본 배치는 setup 안). 오래 사는 채널은 `maxMessages` 로
|
|
479
|
-
`messages` 상한을
|
|
488
|
+
`messages` 상한을 잡는다. 접속자 목록(`members`)은 **드롭·종료에 비워지지 않으므로**
|
|
489
|
+
`status` 를 함께 봐서 렌더한다(옵션·콜백 전체 표는 `agents/realtime.md` §4).
|
|
480
490
|
- **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
|
|
481
491
|
중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
|
|
482
492
|
- **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
|
|
@@ -523,7 +533,7 @@ async function runSearch(q: string) {
|
|
|
523
533
|
| 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
|
|
524
534
|
| 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
|
|
525
535
|
| 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
|
|
526
|
-
| 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §
|
|
536
|
+
| 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §4) |
|
|
527
537
|
| 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
|
|
528
538
|
| 결정 299 | 타입 브리지 PropsOf 정정 — 유니온 분배(조건부 redirect 혼합 액션의 never 붕괴 봉합) + `this.json(data)` 언랩(`{json,status}` 래퍼 타입 거짓 봉합 · `JsonResult<T>` 제네릭) (§2) |
|
|
529
539
|
| 결정 300 | pageProps/useShared 부팅 전 접근 가드(수리 안내) + setup-only 근거 정정(usePage=모듈 싱글턴 · inject 아님 · 실측) (§1·알려진 함정) |
|
|
@@ -14,13 +14,21 @@
|
|
|
14
14
|
로케일이 없어 항상 fallback** 이므로, 로케일을 명시적으로 실어 `runWithLanguage`
|
|
15
15
|
로 감싼다(메일은 `deliver(data, { locale })` 로 대칭 · §정본 예시).
|
|
16
16
|
|
|
17
|
+
`locales/ko.json`:
|
|
18
|
+
|
|
17
19
|
```json
|
|
18
|
-
// locales/ko.json
|
|
19
20
|
{ "greeting": "안녕하세요, {{name}}님", "nav": { "home": "홈" } }
|
|
20
|
-
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`locales/en.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
21
26
|
{ "greeting": "Hello, {{name}}", "nav": { "home": "Home" } }
|
|
22
27
|
```
|
|
23
28
|
|
|
29
|
+
카탈로그는 **순수 JSON** 이다 — 주석·트레일링 콤마가 들어가면 로드가 깨진다.
|
|
30
|
+
로케일마다 별도 파일이며 한 파일에 두 언어를 담지 않는다.
|
|
31
|
+
|
|
24
32
|
```ts
|
|
25
33
|
import { t } from 'gaonjs/i18n'
|
|
26
34
|
t('greeting', { name: '가온' }) // 요청 로케일이 ko 면 "안녕하세요, 가온님"
|
|
@@ -41,10 +49,15 @@ t('nav.home') // 중첩은 점 표기
|
|
|
41
49
|
로 부른다 — i18next 가 `count` 와 로케일의 CLDR 규칙으로 알맞은 접미사를 고른다. 타입은
|
|
42
50
|
**base 키**(`<키>`)로 검사한다(생성기가 접미사 키에서 base 키를 함께 노출 · 결정 181).
|
|
43
51
|
|
|
52
|
+
`locales/en.json` — 영어는 단수/복수 구분(`_one`·`_other`):
|
|
53
|
+
|
|
44
54
|
```json
|
|
45
|
-
// locales/en.json — 영어는 단수/복수 구분(_one·_other)
|
|
46
55
|
{ "cart": { "items_one": "{{count}} item", "items_other": "{{count}} items" } }
|
|
47
|
-
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`locales/ko.json` — 한국어는 복수 구분 없음(`_other` 만):
|
|
59
|
+
|
|
60
|
+
```json
|
|
48
61
|
{ "cart": { "items_other": "상품 {{count}}개" } }
|
|
49
62
|
```
|
|
50
63
|
|
|
@@ -22,6 +22,28 @@
|
|
|
22
22
|
등록 (다른 배터리와 같은 관례). 파일명이 채널 이름이 되고, default
|
|
23
23
|
export 를 런타임이 집는다. 파일명은 camelCase (루트 §네이밍).
|
|
24
24
|
|
|
25
|
+
**채널 이름은 전역 네임스페이스다 — 앱 소속이 아니다.** 파일이 앱 폴더 아래
|
|
26
|
+
있는 것은 **정의(훅·인가)와 WS 접속 경로**가 앱에 매인다는 뜻이고, **이름으로
|
|
27
|
+
식별되는 것들은 전부 앱을 가로지른다**:
|
|
28
|
+
|
|
29
|
+
| 축 | 스코프 | 실체 |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| WS 접속 경로 | **앱** | `<앱 프리픽스>/gaon/ws/<이름>`(web 의 `room`→`/gaon/ws/room` · admin 의 `adminRoom`→`/admin/gaon/ws/adminRoom`) |
|
|
32
|
+
| `authorize`·`presenceInfo`·훅 | **앱** | 그 앱 폴더의 채널 파일이 실행된다 |
|
|
33
|
+
| 발화(`broadcast`·`sendToUsers`) | **전역** | NATS subject `gaon.chan.<이름>` — 이름만 씀 |
|
|
34
|
+
| 프레즌스 로스터 | **전역** | 허브 KV 키 `presence.<이름>.<멤버>` — 이름만 씀 |
|
|
35
|
+
|
|
36
|
+
- **앱간 동명 채널은 충돌한다.** `apps/web/channels/room.ts` 와
|
|
37
|
+
`apps/admin/channels/room.ts` 를 함께 두면 **접속 경로는 갈리지만 subject·프레즌스
|
|
38
|
+
키는 하나**다 — admin 구독자가 web 발화를 받고, 두 앱 접속자가 한 로스터에 섞인다.
|
|
39
|
+
각 앱의 `authorize` 는 **자기 경로로 들어온 연결만** 거르므로, 한쪽 앱의 엄격한
|
|
40
|
+
인가가 다른 앱 경로로 들어온 구독자를 막아 주지 못한다.
|
|
41
|
+
- **그래서 이름을 전역에서 고유하게 짓는다** — 앱별로 갈라야 하면 이름에 접두를
|
|
42
|
+
넣는다(`adminRoom.ts`·`webRoom.ts`). 서버 발화 `broadcast('room', …)` 도 이름 하나로
|
|
43
|
+
전 서버·전 앱 구독자에게 가므로(§2.5), 이름이 곧 격리 경계다.
|
|
44
|
+
- 발화 대상을 좁혀야 하면 **채널을 분리**한다(§2.5 · `authorize` 로 입장을 제한하고 그
|
|
45
|
+
채널로 broadcast) — broadcast 자체에는 대상 필터가 없다. 특정 유저 지정은 `sendToUsers`(§2.6).
|
|
46
|
+
|
|
25
47
|
```ts
|
|
26
48
|
// apps/web/channels/room.ts
|
|
27
49
|
import { channel } from 'gaonjs/async'
|
|
@@ -185,8 +207,16 @@ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{
|
|
|
185
207
|
을 손으로 짜지 말 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
|
|
186
208
|
자동 인증, JWT 앱은 `params: () => ({ access_token: token.value })` — **함수형으로
|
|
187
209
|
넘겨라**(결정 344). params 는 접속·재접속 시점마다 평가되므로 함수형이면 회전한
|
|
188
|
-
토큰이 재연결에 반영된다(고정 객체는 최초 값
|
|
189
|
-
|
|
210
|
+
토큰이 재연결에 반영된다(고정 객체는 최초 값 고정이라 만료 후 재연결이 계속 옛
|
|
211
|
+
토큰으로 붙는다). room=42 같은 불변 값은 고정 객체로 넘겨도 된다.
|
|
212
|
+
|
|
213
|
+
- **무효·만료 토큰 자체는 연결 거부가 아니라 익명 강등이다.** JWT 앱의 멤버 해석은
|
|
214
|
+
`access_token` 이 유효하면 `user:<sub>`, **아니면 조용히 `conn:<uuid>`·`user=null`**
|
|
215
|
+
로 떨어진다(`packages/web/src/app.ts` memberResolverFor). 그래서 만료 토큰으로 붙어도
|
|
216
|
+
공개 채널(`authorize` 없음)이면 **접속은 성공**하고, `authorize(ctx) { return ctx.user != null }`
|
|
217
|
+
같은 게이트가 있을 때 비로소 **4401** 로 끊긴다 — 4401 은 언제나 `authorize` 거부의
|
|
218
|
+
결과지 토큰 검증 실패 코드가 아니다(§2 `authorize`). 인증이 필요한 채널은 반드시
|
|
219
|
+
`authorize` 를 둔다(토큰만 믿고 생략하면 만료 사용자가 익명으로 입장한다).
|
|
190
220
|
|
|
191
221
|
**앱 프리픽스는 자동이다(결정 154).** 서버는 채널 WS 를 `<앱 프리픽스>/gaon/ws/:channel`
|
|
192
222
|
에 등록하고, `useChannel` 은 그 앱 번들의 `import.meta.env.BASE_URL`(= vite base = 앱
|
|
@@ -196,23 +226,38 @@ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{
|
|
|
196
226
|
로만 남는다(명시하면 그대로 쓴다). 프리픽스를 손으로 넣던 옛 관례는 폐기됐다.
|
|
197
227
|
|
|
198
228
|
```ts
|
|
199
|
-
// apps/web/composables/useRoom.ts — 컴포저블에 래핑(agents/frontend.md §
|
|
229
|
+
// apps/web/composables/useRoom.ts — 컴포저블에 래핑(agents/frontend.md §4)
|
|
200
230
|
import { useChannel } from 'gaonjs/vue'
|
|
201
231
|
|
|
202
232
|
export function useRoom(roomId: number) {
|
|
203
233
|
// messages·members(반응형)·status·send·connect·close 를 돌려준다. 마운트에 접속.
|
|
204
234
|
const { messages, members, status, send } = useChannel('room', {
|
|
205
235
|
params: { room: roomId },
|
|
206
|
-
onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
|
|
236
|
+
onMessage: (data, frame) => { /* 서버가 broadcast/send 한 데이터 · frame = 원 봉투 */ },
|
|
207
237
|
// members = 현재 접속자 **전체 명단**. 스냅샷·들어옴·나감을 하나로 반영하므로
|
|
208
238
|
// 델타를 손으로 병합하지 않는다. 반응형이라 template 에서 그대로 렌더해도 된다.
|
|
209
|
-
|
|
239
|
+
// 둘째 인자 frame 으로 무엇이 트리거했는지 안다(frame.t = presence/join/leave).
|
|
240
|
+
onPresence: (members, frame) => { /* 접속자 명단이 바뀔 때마다 전체 명단으로 호출 */ },
|
|
210
241
|
onReconnect: () => { /* 재연결됨 — 놓친 데이터를 Inertia partial reload 로 따라잡기 */ },
|
|
211
242
|
})
|
|
212
243
|
return { messages, members, status, send }
|
|
213
244
|
}
|
|
214
245
|
```
|
|
215
246
|
|
|
247
|
+
**옵션·콜백 표** (`ChannelOptions` · `packages/vue/src/useChannel.ts`):
|
|
248
|
+
|
|
249
|
+
| 옵션 | 기본 | 뜻 |
|
|
250
|
+
|---|---|---|
|
|
251
|
+
| `params` | — | 쿼리 파라미터. **함수형이면 접속·재접속마다 재평가**(회전 토큰 · 결정 344) |
|
|
252
|
+
| `immediate` | `true` | 마운트 시 자동 접속. `false` 면 접속하지 않고 **`connect()` 를 직접 부른다** |
|
|
253
|
+
| `maxMessages` | 무제한 | `messages` 보관 상한(초과분은 오래된 것부터 버림 · 결정 303) |
|
|
254
|
+
| `reconnect` | `true` | 자동 재연결. `false` 로 끄거나 `{ curveMs, maxDelayMs, maxAttempts }` 로 튜닝 |
|
|
255
|
+
| `path` | 자동 | WS 경로 override(탈출구 · 기본은 앱 프리픽스 자동 · 결정 154) |
|
|
256
|
+
| `onMessage(data, frame)` | — | `msg` 프레임 — `messages` 축적과 함께 호출 |
|
|
257
|
+
| `onPresence(members, frame)` | — | 접속자 명단 변화 — 첫 인자는 **갱신된 전체 명단**, 둘째는 트리거한 프레임 |
|
|
258
|
+
| `onFrame(frame)` | — | **모든 프레임**에 호출(아래) |
|
|
259
|
+
| `onReconnect()` | — | 재연결 **성공** 시(최초 접속엔 호출 안 됨) |
|
|
260
|
+
|
|
216
261
|
- **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
|
|
217
262
|
정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다. **소켓이 OPEN 이 아니면
|
|
218
263
|
(접속 전·재연결 중) 보내지 않고 `false` 를 반환한다**(결정 303 · 큐잉 없음) — 유실이
|
|
@@ -221,18 +266,40 @@ export function useRoom(roomId: number) {
|
|
|
221
266
|
채널은 `maxMessages: 200` 처럼 상한을 잡는다(초과분은 오래된 것부터 버림 · 결정 303).
|
|
222
267
|
- **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출. 접속자 프레임(초기
|
|
223
268
|
스냅샷 + 이후 들어옴/나감)은 하나의 **명단**으로 합쳐져 반응형 `members` 에 반영되고
|
|
224
|
-
`onPresence(members)` 로도 통지된다(결정 272 · 콜백만으로 항상 최신 명단 · 델타
|
|
225
|
-
불요).
|
|
269
|
+
`onPresence(members, frame)` 로도 통지된다(결정 272 · 콜백만으로 항상 최신 명단 · 델타
|
|
270
|
+
병합 불요).
|
|
271
|
+
- **`onFrame` 은 "그 외"가 아니라 *모든* 프레임에 호출된다** — `msg`·`presence`·
|
|
272
|
+
`presence:join`·`presence:leave` 를 각각 처리한 **뒤에도** 매번 호출되는 저수준 훅이다.
|
|
273
|
+
그래서 `onMessage` 와 `onFrame` 을 함께 쓰면 같은 `msg` 프레임을 **두 번** 보게 된다 —
|
|
274
|
+
`onFrame` 안에서 처리하려면 `frame.t` 로 직접 갈라라(중복 집계 주의).
|
|
275
|
+
- **`members` 는 드롭·종료에 비워지지 않는다** — 로스터는 서버가 보내는 `presence`
|
|
276
|
+
스냅샷으로만 교체된다. 소켓이 끊겨 `status` 가 `'reconnecting'` 이 돼도, `close()`
|
|
277
|
+
로 끊어 `'closed'` 가 돼도 `members` 에는 **마지막 명단이 그대로 남는다**(스테일 렌더).
|
|
278
|
+
접속자 목록 UI 는 `status` 를 함께 봐서 끊긴 동안 흐리거나 숨긴다 — 재연결이 성공하면
|
|
279
|
+
서버가 새 스냅샷을 밀어 자동으로 정확해진다.
|
|
280
|
+
```vue
|
|
281
|
+
<ul v-if="status === 'open'"><li v-for="m in members" :key="m.id">{{ m.info?.name }}</li></ul>
|
|
282
|
+
<p v-else>접속자 목록 동기화 중…</p>
|
|
283
|
+
```
|
|
226
284
|
- **자동 재연결(결정 128 · 기본 켬)** — 소켓이 끊기면(서버 재시작·네트워크 blip)
|
|
227
285
|
useChannel 이 **지수 백오프**(1s·2s·5s·10s · 이후 10s 반복 · 지터)로 자동 재접속한다.
|
|
228
286
|
새 소켓은 서버가 다시 인가하고 프레즌스 스냅샷을 다시 밀어주므로 접속자 목록이
|
|
229
287
|
재동기된다(`messages` 는 유지 · 놓친 이벤트 리플레이는 범위 밖). `status` 는
|
|
230
288
|
`'connecting' | 'open' | 'reconnecting' | 'closed'` 로, 드롭 후 `'reconnecting'`,
|
|
231
289
|
성공하면 `'open'`. **재연결 성공 시 `onReconnect` 콜백**으로 놓친 데이터를 따라잡는다
|
|
232
|
-
(Inertia partial reload).
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
290
|
+
(Inertia partial reload). 끄려면 `reconnect: false`, 튜닝은
|
|
291
|
+
`reconnect: { curveMs, maxDelayMs, maxAttempts }`. 언마운트·수동 `close()` =
|
|
292
|
+
의도적 종료라 재연결하지 않는다.
|
|
293
|
+
- **재연결하지 않는 종단 close code 2종** — 서버 다운(재시도)과 계약 위반(포기)을
|
|
294
|
+
코드로 가른다. 둘 다 `status` 가 `'closed'` 로 고정되므로, 앱은 이 상태를 사용자에게
|
|
295
|
+
알리고 재시도 수단(로그인·새로고침)을 준다.
|
|
296
|
+
|
|
297
|
+
| code | 언제 | 뒤처리 |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| `4401` | 채널 `authorize` 거부(비로그인·세션 만료·만료 토큰의 익명 강등 포함) | 재연결 안 함 — 로그인으로 유도 |
|
|
300
|
+
| `4500` | **seal 개봉 실패**(봉인 계약 위반 · 결정 222·318) | 재연결 안 함 — transient 가 아니라 주입/변조/키 불일치. 콘솔에 원인이 찍힌다(`agents/seal.md`) |
|
|
301
|
+
|
|
302
|
+
그 외 코드(서버 재시작·네트워크 blip·핸들러 실패 `1011`)는 전부 자동 재연결 대상이다.
|
|
236
303
|
- **탈출구** — 표준 WebSocket 이 필요하면 `new WebSocket('<프리픽스>/gaon/ws/<채널명>')`
|
|
237
304
|
을 직접 쓸 수 있다(봉투·라이프사이클·재연결을 스스로 책임진다). 기본 경로는 `useChannel`.
|
|
238
305
|
|
|
@@ -269,7 +336,23 @@ export function useRoom(roomId: number) {
|
|
|
269
336
|
- **멀티호스트는 `GAON_HUB_ADVERTISE` 필수.** KV 에 공지하는 도달 주소의 기본은
|
|
270
337
|
`127.0.0.1:<port>` 라 **단일 호스트 전용**이다 — 웹서버가 다른 호스트에 있으면
|
|
271
338
|
자기 localhost 로 붙으려다 무한 백오프에 빠진다. 웹서버가 도달 가능한 주소
|
|
272
|
-
(예: `hub.internal:4001`)를 `GAON_HUB_ADVERTISE` 로 준다(
|
|
339
|
+
(예: `hub.internal:4001`)를 `GAON_HUB_ADVERTISE` 로 준다(운영 배치 가이드는
|
|
340
|
+
https://gaonjs.dev 의 operations 문서).
|
|
341
|
+
- **허브 env 전체(`gaon hub` 가 읽는 값 · `packages/cli/src/hub.ts`):**
|
|
342
|
+
|
|
343
|
+
| env | 기본값 | 무엇 |
|
|
344
|
+
|---|---|---|
|
|
345
|
+
| `GAON_HUB_PORT` | `4001` | TCP 리슨 포트(웹서버 접속 지점 · 내부망 한정) |
|
|
346
|
+
| `GAON_HUB_HOST` | `0.0.0.0` | 리슨 바인드 주소 — 특정 NIC 로 좁힐 때 |
|
|
347
|
+
| `GAON_HUB_ADVERTISE` | `127.0.0.1:<port>` | KV 에 공지할 **도달 주소**(멀티호스트 필수) |
|
|
348
|
+
| `GAON_HUB_TTL_MS` | `5000` | 리더 리스 TTL(역할별 독립 · 결정 260) |
|
|
349
|
+
| `GAON_HUB_PING_TIMEOUT_MS` | `10000` | 웹서버 소켓 무활동 임계 — 넘으면 끊어 파티션을 회수 |
|
|
350
|
+
| `GAON_HUB_SWEEP_MS` | `2000` | 무활동·회수 스윕 주기 |
|
|
351
|
+
| `GAON_HUB_RECLAIM_GRACE_MS` | `15000` | 서버 끊긴 뒤 멤버를 회수하기까지의 유예(재시작 창) |
|
|
352
|
+
| `GAON_HUB_TOKEN` | (없음) | 공유 토큰 인증(선택 · 아래) — 허브·웹서버 양쪽에 같은 값 |
|
|
353
|
+
|
|
354
|
+
`GAON_HUB_ADVERTISE` 의 포트는 `GAON_HUB_PORT` 와 맞춘다(주소만 바꾸고 포트를
|
|
355
|
+
잊으면 웹서버가 엉뚱한 포트로 붙는다).
|
|
273
356
|
- **허브 TCP 포트는 내부망 전용이다(결정 311).** 포트(기본 4001)는 방화벽/
|
|
274
357
|
보안그룹으로 웹서버 대역에만 연다. 프로토콜 위반 백스톱으로 라인 길이 상한
|
|
275
358
|
(1MiB)을 두며, 초과 소켓은 즉시 끊는다(fail-closed · 개행 없는 스트림의
|
|
@@ -330,8 +413,13 @@ export default channel({
|
|
|
330
413
|
|
|
331
414
|
## 알려진 함정
|
|
332
415
|
|
|
333
|
-
- **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (
|
|
334
|
-
|
|
416
|
+
- **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (정의·인가·WS 경로가
|
|
417
|
+
라우트처럼 앱 경계 안).
|
|
418
|
+
- **채널 *이름* 은 전역이다 — 앱 소속이 아니다(§2).** 파일이 앱 아래 있다고 이름까지
|
|
419
|
+
앱별로 갈리는 게 아니다: 발화 subject(`gaon.chan.<이름>`)와 프레즌스 키
|
|
420
|
+
(`presence.<이름>.<멤버>`)는 이름만 쓴다. 두 앱에 같은 이름의 채널 파일을 두면
|
|
421
|
+
구독자·로스터가 섞이고, 한쪽 `authorize` 가 다른 앱 경로의 구독자를 막지 못한다.
|
|
422
|
+
이름을 전역 고유로 짓는다(`adminRoom`·`webRoom`).
|
|
335
423
|
- **`presenceInfo` 에 민감 정보 금지** — 접속자 목록은 채널 전원에게
|
|
336
424
|
공개된다. 공개 메타만.
|
|
337
425
|
- **raw data 에코는 반정본** — `onMessage(ctx, data) { ctx.broadcast(data) }` 처럼
|
|
@@ -364,6 +452,7 @@ export default channel({
|
|
|
364
452
|
| 결정 | 내용 |
|
|
365
453
|
|---|---|
|
|
366
454
|
| E-2 | 웹서버 ↔ 허브 = TCP 지속 연결 · NATS = broadcast 전용 |
|
|
455
|
+
| §7 채널 네임스페이스 | **채널 이름 = 전역 네임스페이스**(§2) — WS 접속 경로·훅·`authorize` 만 앱 스코프, 발화 subject(`gaon.chan.<이름>`)·프레즌스 키(`presence.<이름>.<멤버>`)는 이름 단위 · 앱간 동명 채널은 구독자·로스터가 섞이므로 이름을 전역 고유로 |
|
|
367
456
|
| §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
|
|
368
457
|
| 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
|
|
369
458
|
| 결정 154 | `useChannel` 앱 프리픽스 자동 주입 — `import.meta.env.BASE_URL`(vite base·에셋 base 단일 소스) 로 `<프리픽스>/gaon/ws/<name>` · `opts.path` 는 탈출구 · 프리픽스 앱 실시간 무한 재연결 제거(§4) |
|
|
@@ -375,7 +464,8 @@ export default channel({
|
|
|
375
464
|
| 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
|
|
376
465
|
| 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
|
|
377
466
|
| 결정 303 | `useChannel` 계약 3정비(§4) — `send()` 는 OPEN 아니면 `false`(무신호 드롭 봉합 · 큐잉 없음) · 컴포넌트 밖 호출 = 즉시 접속(라이프사이클 훅 미발화로 영원히 closed 이던 무신호 미접속 봉합 · 정리는 호출자 `close()`) · `maxMessages` 상한 옵션(초과분 오래된 것부터 버림) |
|
|
378
|
-
| 결정
|
|
467
|
+
| 결정 222·318 | seal 개봉 실패 = WS `4500` 종단(§4) — 서버 wsTerminator 와 대칭 · transient 가 아니라 재연결하지 않음(`agents/seal.md`) |
|
|
468
|
+
| 결정 344 | `useChannel` 함수형 `params`(§4) — 접속·재접속 시점마다 평가해 회전 토큰(JWT access_token) 반영 · 고정 객체는 최초 값 고정이라 만료 토큰으로 재접속(→ 익명 강등 → `authorize` 거부 시 4401 종단)하던 갭 봉합 |
|
|
379
469
|
| 결정 307 | `onJoin`/스냅샷 실패 = 프레즌스 보상 해제(§2) — join 후반 실패 시 이미 발신한 프레즌스 등록을 자동 회수(leave)·로컬 연결 정리 후 rethrow · 접속 못 한 멤버가 로스터에 유령으로 남던 결함 봉합 |
|
|
380
470
|
| 결정 309 | `onLeave` throw 에도 로컬 정리 계속(§2) — conns 회수·채널 teardown 을 finally 로 · "로그만 남기고 정리를 계속" 문서 계약과 코드 정합(conn·구독 누수 봉합) |
|
|
381
471
|
| 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 방화벽으로 내부망 한정 |
|
|
@@ -65,17 +65,24 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
|
|
|
65
65
|
|
|
66
66
|
### 1. 켜는 법 — The One Way (결정 121·124)
|
|
67
67
|
|
|
68
|
+
1) 설치 — 선택 플러그인이라 기본 스캐폴드에 없다(프로젝트의 패키지 매니저를 쓴다):
|
|
69
|
+
|
|
68
70
|
```bash
|
|
69
|
-
|
|
71
|
+
npm i @gaonjs/seal # pnpm add @gaonjs/seal · yarn add @gaonjs/seal 동형
|
|
70
72
|
```
|
|
73
|
+
|
|
74
|
+
2) 앱 설정에서 켠다:
|
|
75
|
+
|
|
71
76
|
```ts
|
|
72
|
-
//
|
|
77
|
+
// apps/<앱>/app.config.ts — 앱 wire 전체 봉인(요청/응답 JSON + 최초 문서 data-page).
|
|
73
78
|
export default defineAppConfig({
|
|
74
79
|
seal: true, // 또는 { except: ['/webhooks/*'], strictQuery: true } — except = 외부가 seal 을 모르는 경로만 평문 통과 · strictQuery = 평문 쿼리도 거부(결정 354)
|
|
75
80
|
})
|
|
76
81
|
```
|
|
82
|
+
3) 클라이언트를 배선한다:
|
|
83
|
+
|
|
77
84
|
```ts
|
|
78
|
-
//
|
|
85
|
+
// apps/<앱>/main.ts — seal 클라이언트를 정적 import 해 createGaonApp 에 넘긴다.
|
|
79
86
|
import { createGaonApp } from 'gaonjs/vue'
|
|
80
87
|
import * as sealClient from '@gaonjs/seal/client' // 정적 import (사용자 vite 가 wasm 포함 번들)
|
|
81
88
|
void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
@@ -98,7 +98,10 @@
|
|
|
98
98
|
**공개 회원가입(registration)을 깔지 않는다** — 관리 앱에 공개 가입이 열리고
|
|
99
99
|
로그인한 일반 고객이 관리 화면을 보던 위험 기본을 구조적으로 막는다. 대신 보호
|
|
100
100
|
라우트에 **역할 게이트**(`this.requireAuth()` + `this.authorize(user.role === 'admin')`
|
|
101
|
-
· 결정 145)를 예시로 깔고, `domain/schema/users.ts` 에 `role`
|
|
101
|
+
· 결정 145)를 예시로 깔고, `domain/schema/users.ts` 에 `role` 컬럼(`t.string().default('user')`)
|
|
102
|
+
을 두라고 안내한다. **컬럼과 함께 `apps/<app>/auth.ts` 의 `GaonCurrentUser` 증강에도
|
|
103
|
+
`role: string` 을 더한다** — 증강은 손 선언이라 스키마와 자동 동기되지 않아, 빠뜨리면
|
|
104
|
+
역할 게이트가 TS2339 로 컴파일에서 막힌다(아래 ② · `agents/web.md` §6 · 결정 58).
|
|
102
105
|
관리자는 직접 만들거나 승격한다(공개 가입 라우트 없음). web 앱은 현행대로 공개 가입 O.
|
|
103
106
|
공개 비-web 앱이 필요하면 `--public` 로 공개 가입을 opt-in 한다.
|
|
104
107
|
- CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF 강제.
|
|
@@ -150,6 +153,14 @@
|
|
|
150
153
|
**403**. 존재 자체를 숨겨야 하면 `this.authorize(condition, { notFound: true })` → 404.
|
|
151
154
|
조건은 호출자가 계산한다(예: `this.authorize(this.currentUser?.role === 'admin')`).
|
|
152
155
|
일회성 규칙·탈출구다.
|
|
156
|
+
- **역할 게이트를 쓰려면 `role` 을 스키마와 타입 증강 양쪽에 더한다.** `currentUser`
|
|
157
|
+
의 타입은 `apps/<app>/auth.ts` 의 `GaonCurrentUser` **선언 병합 증강**이고, 스캐폴드
|
|
158
|
+
기본값은 `{ id, name, email }` 뿐이라 스키마에 `role` 컬럼만 추가하면 위 예시가
|
|
159
|
+
**TS2339**(`role` 없음)로 컴파일에서 막힌다. 두 곳을 함께 고친다 —
|
|
160
|
+
① `domain/schema/users.ts` 에 `role: t.string().default('user')` 등 컬럼 추가 +
|
|
161
|
+
마이그레이션, ② `apps/<app>/auth.ts` 의 증강에 `role: string` 추가
|
|
162
|
+
(증강은 자동 생성이 아니라 손 선언이라 스키마와 자동 동기되지 않는다 ·
|
|
163
|
+
`agents/web.md` §6 · 결정 58).
|
|
153
164
|
- **③ 정책 객체 `policy()` + `this.can`** (결정 149) — **재사용할 인가 규칙**을 리소스별
|
|
154
165
|
"액션 → 조건 함수"로 묶는다. 가드는 저수준 authorize 로 수렴한다:
|
|
155
166
|
|
|
@@ -280,6 +291,7 @@ const rows = await Post.query()
|
|
|
280
291
|
| 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
|
|
281
292
|
| 결정 120 | 클라이언트 IP 신뢰 = `web.clientIp` direct/proxy/header · 헤더는 신뢰 홉 전제에서만 · IP 는 약한 신호(인가 금지) · `this.request.ip` 단일 산출(§6 · `agents/web.md` §4.4) |
|
|
282
293
|
| 결정 122 | hidden 계약은 관계(`include`/지연) 행에도 적용 — render props 로 나가는 모든 값은 `serializeProps` 통과 후 hidden 컬럼명 부재 |
|
|
294
|
+
| 결정 58 | 역할 게이트 전제 — `currentUser` 타입은 `apps/<app>/auth.ts` 의 `GaonCurrentUser` 증강(손 선언) · schema 에 `role` 추가 시 증강도 갱신(미갱신 = TS2339 · §2 · `agents/web.md` §6) |
|
|
283
295
|
| 결정 145 | 인가 프리미티브 `this.authorize(cond)` — 거짓 → 403(존재 은닉 시 404) · 인증(401)과 별개 축 · 저수준 탈출구 |
|
|
284
296
|
| 결정 149 | 인가 정책 객체 `policy()` + `this.can` — 재사용 규칙을 리소스별 액션→조건으로 묶음 · 값 객체(레지스트리 아님) · 가드는 `authorize(can(...))` 로 수렴 · authorize(cond) 무회귀(§2) |
|
|
285
297
|
| 결정 155 | `gaon g auth --app <비-web>` 시큐어 기본 — 공개 회원가입 미생성 + 역할 게이트(authorize) 예시 · web=공개가입 · `--public` opt-in(§2) |
|