@gaonjs/cli 0.42.3 → 0.47.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/README.md +1 -1
- package/dist/commands/db.js +29 -7
- package/dist/commands/new.d.ts +2 -0
- package/dist/commands/new.js +8 -3
- package/dist/db/reset.js +20 -10
- package/dist/db/resolve.d.ts +15 -0
- package/dist/db/resolve.js +29 -0
- package/dist/doctor/pageprops-destructure.d.ts +2 -2
- package/dist/doctor/pageprops-destructure.js +29 -23
- package/dist/doctor/schema-relations.js +6 -1
- package/dist/doctor.js +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +18 -2
- package/dist/mcp/tools.js +15 -1
- package/dist/scaffold/job.js +3 -1
- package/dist/templates/project/.dockerignore.tpl +3 -0
- package/dist/templates/project/.env.example.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/CLAUDE.md.tpl +4 -3
- package/dist/templates/project/Dockerfile.tpl +6 -1
- package/dist/templates/project/agents/async.md.tpl +44 -6
- package/dist/templates/project/agents/data.md.tpl +206 -15
- package/dist/templates/project/agents/frontend.md.tpl +36 -8
- package/dist/templates/project/agents/mail.md.tpl +2 -1
- package/dist/templates/project/agents/realtime.md.tpl +26 -2
- package/dist/templates/project/agents/seal.md.tpl +9 -1
- package/dist/templates/project/agents/security.md.tpl +18 -0
- package/dist/templates/project/agents/storage.md.tpl +15 -8
- package/dist/templates/project/agents/web.md.tpl +38 -2
- package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
- package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
- package/dist/templates/project/apps/web/main.ts.tpl +1 -1
- package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
- package/dist/templates/project/docker-compose.yaml.tpl +1 -1
- package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
- package/dist/templates/project/vite.config.ts.tpl +1 -1
- package/dist/work.d.ts +21 -0
- package/dist/work.js +45 -1
- package/package.json +11 -6
- package/dist/doctor/shared-composable-purity.d.ts +0 -8
- package/dist/doctor/shared-composable-purity.js +0 -164
- package/dist/templates/index.ts +0 -109
|
@@ -175,6 +175,18 @@ await OrderPlaced.emit({ orderId: 1n })
|
|
|
175
175
|
|
|
176
176
|
여러 리스너가 같은 이벤트를 durable 컨슈머로 구독하며, 각자 재시도된다.
|
|
177
177
|
|
|
178
|
+
**리스너 재시도·폐기 계약 (결정 308 명문화).** 리스너 실패는 잡보다 단순하게
|
|
179
|
+
다룬다 — **DLQ 가 없다**:
|
|
180
|
+
|
|
181
|
+
- 실패하면 백오프(잡과 같은 곡선 `[1s, 5s, 30s, 5m, 1h]`)로 재전달되고, 최대
|
|
182
|
+
재전달(기본 6 · 최초 포함) 소진 시 **영구 폐기**된다. 즉 계속 실패하는 이벤트는
|
|
183
|
+
약 **1시간 36분** 뒤 사라진다 — `gaon work` 가 `✗ 이벤트 폐기` 로 신호한다
|
|
184
|
+
(결정 308 · 이전엔 human 모드 무신호). 놓치면 안 되는 처리는 리스너에서 잡을
|
|
185
|
+
발행해(`.later()`) 잡의 재시도·DLQ 배터리로 넘긴다.
|
|
186
|
+
- **리스너별 순차 처리는 보장되지 않는다** — 재시도 대기 중 다음 이벤트가 먼저
|
|
187
|
+
처리될 수 있고, 드물게 같은 리스너의 두 이벤트가 겹칠 수 있다. 순서·중복에
|
|
188
|
+
기대지 말고 **멱등**하게 짠다(at-least-once 계약과 동일 축).
|
|
189
|
+
|
|
178
190
|
### 4. 아웃박스 (트랜잭션 정합)
|
|
179
191
|
|
|
180
192
|
이벤트를 DB 트랜잭션과 **원자적으로** 발행하려면 아웃박스를 쓴다.
|
|
@@ -211,10 +223,17 @@ export const PlaceOrder = service(async (input: { name: string }) => {
|
|
|
211
223
|
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
|
|
212
224
|
기동 시 보장된다(결정 144 · nats 설정이 있을 때).
|
|
213
225
|
- 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
|
|
214
|
-
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
226
|
+
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격·폴링 주기는 env
|
|
227
|
+
`GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`
|
|
228
|
+
로 조정한다(결정 312 · `GAON_WORKER_*` 와 대칭 · `gaon work` 가 읽는다). 프로그래매틱
|
|
229
|
+
경로는 `runWork()` 의 `outboxRetentionMs`·`outboxPurgeIntervalMs`·`relayPollMs`.
|
|
230
|
+
미발행 행은 절대 삭제되지 않는다.
|
|
231
|
+
- **발행 실패는 행 단위로 격리된다(결정 306).** 한 행의 publish 가 실패해도(예:
|
|
232
|
+
페이로드가 NATS `max_payload` 1MiB 초과) 그 행만 미발행으로 남아 재시도되고,
|
|
233
|
+
뒤 행들은 정상 발행된다 — 한 행이 아웃박스 전체를 조용히 세우지 않는다. 실패는
|
|
234
|
+
`gaon work` 에 `⚠ 아웃박스 릴레이 오류` 로 신호된다(행 id 별 60s 스로틀).
|
|
235
|
+
이벤트 페이로드는 1MiB 미만으로 유지한다 — 큰 데이터는 본문 대신 id 를 실어
|
|
236
|
+
리스너가 조회하게 한다.
|
|
218
237
|
|
|
219
238
|
### 5. 스케줄러
|
|
220
239
|
|
|
@@ -242,6 +261,18 @@ export default schedule((s) => {
|
|
|
242
261
|
| `s.daily.at('HH:MM', Job)` | 매일 지정 시각 |
|
|
243
262
|
| `s.cron('분 시 일 월 요일', Job)` | 5필드 크론 표현식 |
|
|
244
263
|
|
|
264
|
+
- **스케줄 대상 잡은 무인자여야 한다(결정 310).** 스케줄러는 인자 없이 발화하므로
|
|
265
|
+
인자 필수 잡(`job(async (userId: bigint) => ...)`)을 등록하면 **컴파일 에러**로
|
|
266
|
+
거부된다(이전엔 통과해 `undefined` 인자로 도는 무신호 버그였다). 인자가 필요하면
|
|
267
|
+
인자를 안에서 결정하는 무인자 잡으로 감싼다(선택 인자 `tag?: string` 는 허용).
|
|
268
|
+
- **크론 부분집합 주의** — 스텝(`/n`)은 범위(`*`·`a-b`)에만 의미가 있다:
|
|
269
|
+
`*/2`·`1-9/2` 는 스텝이고, `5/2` 는 `{5}` 단일 값으로 해석된다(Vixie 계열의
|
|
270
|
+
`5/2`=5부터 스텝 확장은 지원하지 않음). 초 단위·`?`·`L`·`#` 도 범위 밖(§5 서두).
|
|
271
|
+
- **`s.every` 는 프로세스 정지를 따라잡는다** — 절전·일시정지 후 재개하면 밀린
|
|
272
|
+
주기만큼 연속 발화(캐치업 버스트)할 수 있다. 틱당 정확히 1회가 필요하면
|
|
273
|
+
`s.cron`(분 경계 1회)을 쓰거나 잡을 멱등 + `lock(key, fn, { onBusy: 'skip' })`
|
|
274
|
+
으로 지킨다(§중첩 방지).
|
|
275
|
+
|
|
245
276
|
#### 리더 선출 계약 (정본)
|
|
246
277
|
|
|
247
278
|
여러 `gaon work` 인스턴스를 HA 로 띄워도 스케줄이 중복 발행되지 않는 이유:
|
|
@@ -401,8 +432,11 @@ async create() {
|
|
|
401
432
|
소속 · 어느 앱에서든 큐잉).
|
|
402
433
|
- **`export const <Pascal> = job(...)`** — export 없이 정의만 하면
|
|
403
434
|
컨트롤러가 import 해 `.later()` 를 부를 수 없다.
|
|
404
|
-
- **스케줄 대상은 항상
|
|
405
|
-
함수 금지.
|
|
435
|
+
- **스케줄 대상은 항상 잡 · 무인자** — `s.every('10m', async () => ...)` 인라인
|
|
436
|
+
함수 금지. 인자 필수 잡은 컴파일 에러로 거부된다(결정 310 · §5).
|
|
437
|
+
- **계속 실패하는 리스너는 이벤트를 잃는다** — 리스너는 DLQ 가 없어 재전달
|
|
438
|
+
소진(기본 6회 · 약 1h36m) 후 영구 폐기된다(§3 계약 · `gaon work` 가 `✗ 이벤트
|
|
439
|
+
폐기` 로 신호). 유실 불가 처리는 리스너에서 잡으로 넘긴다.
|
|
406
440
|
- **커밋 전 발행 주의** — 트랜잭션 안에서 DB 확정 후에만 나가야 하는
|
|
407
441
|
발행은 `afterCommit()` 또는 아웃박스로.
|
|
408
442
|
- **테스트에서 NATS 목업 금지** (§9) — 실 JetStream 에 접속한다
|
|
@@ -428,4 +462,8 @@ async create() {
|
|
|
428
462
|
| 결정 233 | 크론 리더 페일오버 중복 발행 dedupe (결정론적 dedupe 키 + JetStream 중복 윈도우로 틱당 1회 발행 수렴 · §5) |
|
|
429
463
|
| 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
|
|
430
464
|
| 결정 258 | 워커 소비 루프 복원력(§1) — 재시도/DLQ 발행이 NATS 순단으로 실패해도 큐 소비가 멈추지 않음(nak 재전달 백스톱 · 잡 유실 방지 · 실패 로그) |
|
|
465
|
+
| 결정 306 | 아웃박스 발행 실패 행 격리(§4) — poison 행(페이로드 상한 초과 등)이 배치 전체를 세우지 않음 · 실패 행은 미발행 유지(유실 없음·재시도) · `relay-error` 이벤트로 관측 |
|
|
466
|
+
| 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ 없음·~1h36m) 명문화 |
|
|
467
|
+
| 결정 310 | 스케줄 대상 잡 무인자 가드(§5) — 인자 필수 잡 등록을 컴파일 타임 거부(메서드 bivariance 로 통과해 `undefined` 인자 발화하던 구멍 차단) |
|
|
468
|
+
| 결정 312 | 아웃박스 릴레이 env 튜닝(§4) — `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`(`GAON_WORKER_*` 대칭) |
|
|
431
469
|
| §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
|
|
@@ -55,7 +55,7 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
55
55
|
|
|
56
56
|
| 선언 | 시그니처 | 레코드 접근 | 반환 |
|
|
57
57
|
|---|---|---|---|
|
|
58
|
-
| `t.belongsTo(table)` | `(table)` — **컬럼** | `await post.author()` | `Row` (단건) |
|
|
58
|
+
| `t.belongsTo(table)` | `(table)` — **컬럼** | `await post.author()` | `Row` (단건 · FK 가 `.nullable()` 이면 `Row \| null` — 결정 285) |
|
|
59
59
|
| `hasMany(target, opts?)` | `(target, { foreignKey? })` | `await post.comments()` | `Row[]` |
|
|
60
60
|
| `hasOne(target, opts?)` | `(target, { foreignKey? })` | `await post.cover()` | `Row \| undefined` |
|
|
61
61
|
| `belongsToMany(target, opts?)` | `(target, { through?, foreignKey?, otherKey? })` | `await post.tags()` | `Row[]` |
|
|
@@ -145,26 +145,77 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
145
145
|
서버 코드에서는 그대로 읽힌다(직렬화에서만 제외 · §4.2). hidden 마커는 **열거
|
|
146
146
|
가능한 심볼**이라 `{ ...user }` spread·`Object.assign` 을 넘어 보존된다(결정 253)
|
|
147
147
|
— 단 레코드를 손으로 재구성하거나 JSON 왕복하면 마커가 사라지니 그럴 땐 hidden
|
|
148
|
-
컬럼을 직접 넣지 않는다.
|
|
148
|
+
컬럼을 직접 넣지 않는다. `select()`/`distinct(col)` 로 hidden 컬럼을 **명시 선택**한
|
|
149
|
+
좁은 행에도 마커가 실려 직렬화 경계에서 동일하게 제외된다(결정 283).
|
|
149
150
|
- `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
|
|
150
|
-
- `.index()` — 컬럼 레벨 인덱스 (E-4).
|
|
151
|
+
- `.index()` — 컬럼 레벨 인덱스 (E-4). 옵션 객체로 method·partial 지정 (결정 273):
|
|
152
|
+
- `.index()` — 기본 인덱스. **jsonb 컬럼은 자동 gin**(유연 검색 `@>`·`?` 용 · The One Way), 그 외는 btree.
|
|
153
|
+
- `.index({ using: 'brin' })` — 시계열 정렬 컬럼(예: `createdAt`)에 관례로 BRIN.
|
|
154
|
+
- `.index({ where: "status = 'active'" })` — partial(부분) 인덱스.
|
|
155
|
+
- method 집합 = `btree | gin | brin | gist`. **jsonb→gin 자동은 컬럼 `.index()` 한 곳뿐** —
|
|
156
|
+
brin/gist 는 명시(과유도가 perpetual-diff 를 늘림). **`t.jsonb()` 도 `.index()` 를 가진다**(결정 273 신설).
|
|
151
157
|
- `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4).
|
|
152
158
|
|
|
153
|
-
**테이블 레벨 복합 제약** (E-4 §4.2):
|
|
159
|
+
**테이블 레벨 복합 제약** (E-4 §4.2 · 인덱스 객체 확장 결정 273):
|
|
154
160
|
|
|
155
161
|
```ts
|
|
156
|
-
export const
|
|
157
|
-
|
|
158
|
-
|
|
162
|
+
export const logs = table('logs', {
|
|
163
|
+
userId: t.belongsTo('users'),
|
|
164
|
+
status: t.enum(['active', 'archived'] as const),
|
|
165
|
+
createdAt: t.datetime(),
|
|
166
|
+
meta: t.jsonb<Record<string, unknown>>().index(), // 자동 gin
|
|
159
167
|
...t.timestamps(),
|
|
160
168
|
}, {
|
|
161
|
-
db: 'main',
|
|
162
|
-
unique: [['
|
|
163
|
-
index: [
|
|
164
|
-
|
|
169
|
+
db: 'main', // 커넥션 키 (§7)
|
|
170
|
+
unique: [['userId', 'status']], // 복합 unique
|
|
171
|
+
index: [
|
|
172
|
+
['userId', 'createdAt'], // 복합 btree(문자열 배열 = 기존과 동일)
|
|
173
|
+
{ cols: ['meta'], using: 'gin' }, // jsonb GIN
|
|
174
|
+
{ cols: ['createdAt'], using: 'brin' }, // 시계열 BRIN
|
|
175
|
+
{ cols: ['status'], where: "status = 'active'" }, // partial
|
|
176
|
+
{ expr: "(meta->>'tenant')" }, // 표현식 인덱스(이름 자동 · 해시)
|
|
177
|
+
],
|
|
178
|
+
check: [['positive_age', 'age > 0']], // [name, expr]
|
|
179
|
+
})
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- 인덱스 원소가 **문자열 배열**이면 기존과 100% 동일(btree). **객체**면 `cols`·`expr`·`using`·`where`·`name`.
|
|
183
|
+
- 프레임웍이 만든 인덱스는 **`idx_` 접두**만 관리한다 — raw(수동 튜닝) 인덱스는 diff 가 건드리지
|
|
184
|
+
않으니(drop 계획 안 함), 손수 만든 특수 인덱스는 `idx_` 아닌 이름으로 둔다.
|
|
185
|
+
- **MySQL/MariaDB(legacy §4.5) 커넥션은 gin/brin/gist·partial·표현식 인덱스가 없다** — 선언하면
|
|
186
|
+
`gaon db migrate` 가 **명확히 실패**한다(조용히 btree 로 떨구지 않음). 이런 인덱스는 main(postgres)에.
|
|
187
|
+
|
|
188
|
+
**선언적 파티셔닝** (결정 277 · PostgreSQL · 대용량 로그/이벤트/감사):
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
export const logs = table('logs', {
|
|
192
|
+
id: t.id(),
|
|
193
|
+
createdAt: t.datetime(),
|
|
194
|
+
meta: t.jsonb<Record<string, unknown>>().index(), // 자동 gin
|
|
195
|
+
}, {
|
|
196
|
+
partitionBy: { strategy: 'range', columns: ['createdAt'] }, // 부모만 선언
|
|
165
197
|
})
|
|
166
198
|
```
|
|
167
199
|
|
|
200
|
+
- 전략은 `range | list | hash`. **부모 테이블만 선언**한다 — 파티션 키는 PK 에 자동 편입된다
|
|
201
|
+
(복합 PK `(id, createdAt)`). 개별 **자식 파티션은 스키마 밖**(시간에 따라 증식)이라 헬퍼로 관리한다:
|
|
202
|
+
```ts
|
|
203
|
+
// domain/schedule.ts — 크론 잡으로 명시 실행(자동 마법 없음)
|
|
204
|
+
import { rollMonthlyPartitions } from 'gaonjs/data'
|
|
205
|
+
await rollMonthlyPartitions(db, { table: 'logs', ahead: 1, keep: 6 })
|
|
206
|
+
// ahead=다가올 개월 미리 생성 · keep=6 이면 6개월 지난 파티션 파기(keep 없으면 파기 안 함)
|
|
207
|
+
```
|
|
208
|
+
저수준 헬퍼: `createRangePartition`·`createListPartition`·`createHashPartition`·`createDefaultPartition`·
|
|
209
|
+
`dropPartition`·`listPartitions`. **retention(오래된 파티션 파기)은 절대 자동으로 하지 않는다** —
|
|
210
|
+
`keep` 을 명시하고 그 잡을 실행할 때만 지운다(결정 39 자동 DROP 금지 정신).
|
|
211
|
+
- MySQL/MariaDB 커넥션은 선언적 파티셔닝을 지원하지 않는다(migrate 시 fail-loud).
|
|
212
|
+
|
|
213
|
+
**`t.timestamps()` 와 `update()`** (결정 276): `update()` 는 `updatedAt` 을 **자동으로 현재 시각으로
|
|
214
|
+
갱신**한다(patch 에 `updatedAt` 을 직접 주면 그 값을 존중). 값 변경 없이 시각만 올리려면 `touch()`.
|
|
215
|
+
**자동 갱신은 `rec.update()` 단건 전용이다** — `updateAll()`/`incrementAll` 류 벌크와 `upsert()` 의
|
|
216
|
+
충돌-갱신 경로는 `updatedAt` 을 건드리지 않는다(명시한 컬럼만 쓴다). 벌크로도 갱신 시각을 남기려면
|
|
217
|
+
patch 에 `updatedAt: new Date()` 를 직접 넣는다(결정 286 유보 — 벌크 자동 갱신은 동작 결정 대기).
|
|
218
|
+
|
|
168
219
|
### 4. 체이닝 전체 (`packages/data/src/model.ts`)
|
|
169
220
|
|
|
170
221
|
체이닝 표면은 아래 표가 **전부**다. 표에 없는 메서드
|
|
@@ -236,6 +287,11 @@ export const posts = table('posts', {
|
|
|
236
287
|
| `find` | `(id)` | `Promise<Rec>` | 없으면 **throw** — undefined 를 허용하려면 `where('id', '=', id).first()` |
|
|
237
288
|
| `query` | `()` | Kysely `SelectQueryBuilder` | §5 탈출구 |
|
|
238
289
|
|
|
290
|
+
> **`upsert` 의 `onConflict` 는 MySQL/MariaDB(legacy) 방언에서 문법상 반영되지 않는다** —
|
|
291
|
+
> `ON DUPLICATE KEY UPDATE` 는 충돌 대상을 지정할 수 없어 **어떤 UNIQUE/PK 충돌이든** 갱신을
|
|
292
|
+
> 트리거한다(PG 는 지정한 컬럼 충돌만). unique 제약이 여럿인 테이블에서 방언 간 의미가 갈리니,
|
|
293
|
+
> legacy 커넥션의 upsert 대상 테이블은 충돌 축(unique)을 하나로 유지한다.
|
|
294
|
+
>
|
|
239
295
|
> 벌크 3종은 **전 방언 통일 `BulkResult`**(`{ count, meta? }`)를 돌려준다(결정 90 ·
|
|
240
296
|
> 원안의 "비 RETURNING 방언 throw" 폐기). `count` 는 **처리된 입력 행 수**로 방언
|
|
241
297
|
> 무관 같은 의미다 — MySQL upsert 의 원시 `affectedRows`(삽입 1·갱신 2·무변경 0/1)를
|
|
@@ -398,6 +454,108 @@ export const PlaceOrder = service(async (input: { userId: string; total: number
|
|
|
398
454
|
- **왜 `afterCommit`** — main 이 롤백되면 analytics 기록도 일어나지 않아야 한다.
|
|
399
455
|
`afterCommit` 은 커밋이 성공한 경우에만 콜백을 돈다(§9 · `agents/async.md`).
|
|
400
456
|
|
|
457
|
+
### 7.1 문서형 컬렉션 — `collection()` (v1.1 · `@gaonjs/adapter-mongo` · 결정 278~282)
|
|
458
|
+
|
|
459
|
+
SQL `model()`(Kysely) 옆에 문서형 동사 `collection()` 을 **Mongoose** 위에 둔다.
|
|
460
|
+
로그·이벤트·감사·분석처럼 **문서·유연 스키마·대량 append** 용도다. **`model()` 은
|
|
461
|
+
SQL 전용 · `collection()` 은 문서형** — 한 동사가 두 세계를 처리하지 않는다(The One Way).
|
|
462
|
+
|
|
463
|
+
문서형은 **opt-in 설치**다(SQL 전용 프로젝트는 mongoose 를 받지 않는다):
|
|
464
|
+
|
|
465
|
+
```bash
|
|
466
|
+
npm i @gaonjs/adapter-mongo mongoose
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
// domain/schema/auditLog.ts — mongoSchema() 는 진짜 Mongoose Schema 를 반환한다.
|
|
471
|
+
// 정본 패턴: 문서 인터페이스를 선언하고 mongoSchema<Doc>() 에 명시한다(결정 288).
|
|
472
|
+
import { collection, mongoSchema } from 'gaonjs/data'
|
|
473
|
+
|
|
474
|
+
interface AuditDoc {
|
|
475
|
+
actorId: string
|
|
476
|
+
action: string
|
|
477
|
+
ip?: string // hidden 이어도 서버 코드는 투명하게 읽는다
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const auditLogSchema = mongoSchema<AuditDoc>({
|
|
481
|
+
actorId: { type: String, required: true, index: true },
|
|
482
|
+
action: { type: String, required: true },
|
|
483
|
+
ip: { type: String, hidden: true }, // 직렬화 경계 제외 (SQL .hidden() 과 동일 의미)
|
|
484
|
+
}, { timestamps: true })
|
|
485
|
+
|
|
486
|
+
// 순수 Mongoose 관례 — Gaon 이 메서드 DSL 을 재발명하지 않는다.
|
|
487
|
+
auditLogSchema.statics.recent = function (limit: number) {
|
|
488
|
+
return this.find().sort({ createdAt: -1 }).limit(limit).lean()
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// db: 'logs' — 몽고 커넥션 바인딩 (SQL 의 { db: 'legacy' } 와 대칭)
|
|
492
|
+
export const AuditLog = collection('audit_logs', auditLogSchema, { db: 'logs' })
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
제네릭을 생략해도 정의 지점 추론이 성립하지만(`new mongoose.Schema` 와 동일 ·
|
|
496
|
+
결정 288 이 시그니처를 Mongoose 생성자 복제로 고정), **정본은 명시 인터페이스**다 —
|
|
497
|
+
문서 타입이 코드에 보이고, statics/methods 캐스트 지점에서 재사용한다.
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
// gaon.config.ts — 문서형 커넥션은 adapter: 'mongodb' 로 SQL 과 나란히 선언한다.
|
|
501
|
+
export default defineConfig({
|
|
502
|
+
db: {
|
|
503
|
+
main: { adapter: 'postgres', url: env('DATABASE_URL') },
|
|
504
|
+
logs: { adapter: 'mongodb', url: env('MONGO_URL') },
|
|
505
|
+
},
|
|
506
|
+
})
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
**정본 규칙**:
|
|
510
|
+
- **`hidden` 은 SQL 과 같은 의미** — "서버는 읽고 직렬화만 제외". `select:false` 를
|
|
511
|
+
쓰지 않는다. render props 로 흘릴 땐 **`.lean()`** 을 쓴다(정본 경로 · 경량) —
|
|
512
|
+
`_id`(ObjectId)는 응답 경계에서 자동으로 string 이 된다(결정 282 · 결정 37 문서형
|
|
513
|
+
대응). `create()` 반환 문서·`doc.toObject()` 를 그대로 넘겨도 직렬화 경계가
|
|
514
|
+
방어한다(결정 289 — save/insertMany 훅 + toObject/toJSON transform 마커 + 응답
|
|
515
|
+
경계의 문서 → POJO 정규화). 단 `hidden` 은 **top-level 경로 전용**이다 — 중첩
|
|
516
|
+
경로에 달면 mongoSchema 가 fail-loud 로 던진다(결정 290 · 조용한 유출 방지).
|
|
517
|
+
- **몽고 쓰기를 SQL `service()` tx 안에서 하지 말 것** — 몽고는 v1 에서 트랜잭션이
|
|
518
|
+
없어, tx 안 몽고 쓰기는 `MongoCrossConnectionWriteError` 로 **막힌다**(조용한 부분
|
|
519
|
+
커밋 방지 · 결정 281). "커밋 후 로그" 는 `afterCommit` 으로 잇는다(§7 크로스커넥션과 동일 규율):
|
|
520
|
+
```ts
|
|
521
|
+
export const SignUp = service(async (input) => {
|
|
522
|
+
const user = await Users.create(input) // main(SQL) 트랜잭션
|
|
523
|
+
afterCommit(() => AuditLog.create({ actorId: String(user.id), action: 'signup' })) // 커밋 뒤 몽고 쓰기
|
|
524
|
+
return user
|
|
525
|
+
})
|
|
526
|
+
```
|
|
527
|
+
- **SQL ↔ 문서형 관계 금지** — `belongsTo`/`hasMany` 는 SQL 전용이다. 몽고 컬렉션끼리의
|
|
528
|
+
참조는 Mongoose `ref`/`populate` 로 사용자가 직접 한다(Gaon doctor 가 강제하지 않음).
|
|
529
|
+
- **인덱스 = Mongoose 스키마 선언**(`index: true` / `schema.index()`) — mongoose
|
|
530
|
+
`autoIndex` **기본값**이 모델 첫 사용 시 인덱스를 만든다(dev·prod 공통 · Gaon 이
|
|
531
|
+
별도 배선을 얹지 않는다). prod 에서 끄려면 스키마 옵션 `autoIndex: false` + 배포
|
|
532
|
+
절차에서 `Model.syncIndexes()` 를 직접 부른다. 몽고는 마이그레이션이 없다 —
|
|
533
|
+
`gaon db diff/migrate`(키 생략 = 전 커넥션 순회)는 mongodb 커넥션을 **건너뛰고
|
|
534
|
+
skipped 로 보고**하며(결정 292), `--db <mongo키>` 명시 지정만 fail-loud 다.
|
|
535
|
+
|
|
536
|
+
**알려진 함정**:
|
|
537
|
+
- `aggregate` 결과는 임의 projection(그룹·계산)이라 `hidden` 마커를 심지 **않는다** —
|
|
538
|
+
SQL `Post.query()`(Kysely 원본) raw 탈출구가 hidden 을 우회하는 것과 동형. 문서를
|
|
539
|
+
안전하게 렌더에 흘리려면 `find()/findOne().lean()` 을 쓴다.
|
|
540
|
+
- **크로스커넥션 쓰기 가드(결정 281·291)는 static 메서드 경로만 본다** — 인스턴스
|
|
541
|
+
`document.save()`, **Query 체이닝 쓰기**(`find(f).updateMany(u)`·`where(f).deleteMany()`),
|
|
542
|
+
`aggregate` 의 `$out`/`$merge` 스테이지는 가드 밖이다(Proxy 가 반환된 Query/문서를
|
|
543
|
+
감싸지 않는다). 정본 쓰기는 static `create/insertOne/insertMany/updateOne/…`(가드
|
|
544
|
+
대상 · 결정 291 이 insertOne·bulkSave 편입). tx 안에서 쓸 일이면 `afterCommit`.
|
|
545
|
+
- **`hidden` 은 top-level 경로 전용** — 중첩 def(`profile: { ssn: { hidden: true } }`)에
|
|
546
|
+
달면 mongoSchema 가 던진다(결정 290). top-level 로 옮기거나(`profileSsn`) render
|
|
547
|
+
props 에서 빼라. v1.21(adapter-mongo 0.1.0) 이하에서는 조용히 무시돼 유출됐다.
|
|
548
|
+
- **`database`(또는 url 경로) 생략 시 몽고 드라이버 기본 DB `test` 로 조용히 붙는다** —
|
|
549
|
+
`adapter: 'mongodb'` 커넥션에는 `url`(`mongodb://…/mydb`) 또는 `database` 를 항상
|
|
550
|
+
명시하라. fail-loud 화는 유보(동작 변경) — 현재는 관례로 방어한다.
|
|
551
|
+
- **`service()` 는 SQL 전용** — `service(fn, { db: '<mongo키>' })` 는 SQL 레지스트리
|
|
552
|
+
조회라 "커넥션 미등록" 에러가 난다(안내 문구도 SQL 기준). 몽고 쓰기에는 서비스
|
|
553
|
+
트랜잭션 개념이 없다(v1 몽고 tx 없음) — 그냥 static 으로 쓰거나 SQL 서비스의
|
|
554
|
+
`afterCommit` 에서 쓴다.
|
|
555
|
+
- **mongoSchema 제네릭**: v1.21(adapter-mongo 0.1.0) 이하에서는 제네릭을 생략하면
|
|
556
|
+
문서 타입이 `{ actorId: StringConstructor }` 로 **조용히 붕괴**했다 — 0.2.0(결정
|
|
557
|
+
288)부터 정의 지점 추론이 성립하지만, 정본은 위처럼 명시 인터페이스다.
|
|
558
|
+
|
|
401
559
|
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
|
|
402
560
|
|
|
403
561
|
`model()` 은 스키마(§1)를 Kysely 위의 실행 가능한 API 로 감싼다 —
|
|
@@ -569,6 +727,10 @@ const top = await Post.published().latest().limit(5).withCache(30).all()
|
|
|
569
727
|
**지우지 않는다**. 정합이 복잡하고 틀리면 조용한 stale 을 낳기 때문이다.
|
|
570
728
|
신선도가 중요하면 **짧은 TTL** 을 쓰거나 `cache.forget(key)` 로 명시
|
|
571
729
|
무효화한다. `.withCache` 결과는 TTL 만료로만 갱신된다(자동 퍼지 없음).
|
|
730
|
+
- **적용 지점은 `Chain` 의 `all()`/`first()` 뿐이다** — `.withCache()` 뒤에
|
|
731
|
+
`include()`/`select()`/`distinct(col)` 로 체인이 갈라지거나 `paginate()` 로 끝나면
|
|
732
|
+
캐시가 **조용히 적용되지 않는다**(타입은 통과). 캐시가 필요하면 include/select 없는
|
|
733
|
+
형태로 `all()`/`first()` 마감하거나, 결과 조립 전체를 `cache.remember` 로 감싼다.
|
|
572
734
|
- **키는 호출자가 정한다** — 로케일·사용자 등 변이 축은 **키에 직접 넣는다**
|
|
573
735
|
(`` `page:${locale}` ``). 프레임웍이 변이 축을 자동으로 섞지 않는다.
|
|
574
736
|
- 백엔드는 Redis 가 기본(설정에 `redis` 있으면 자동), 메모리는 dev/테스트/폴백.
|
|
@@ -591,8 +753,9 @@ export const PublishPost = service(async (postId: bigint) => {
|
|
|
591
753
|
return published
|
|
592
754
|
})
|
|
593
755
|
|
|
594
|
-
//
|
|
595
|
-
// const
|
|
756
|
+
// 컨트롤러에서 (결정 294: this.params('id') 같은 단일 키 접근 표면은 없다):
|
|
757
|
+
// const { id } = this.params({ _row: {} as { id: string } })
|
|
758
|
+
// const post = await PublishPost.call(BigInt(id))
|
|
596
759
|
```
|
|
597
760
|
|
|
598
761
|
- **시그니처** — `service(handler, options?)` → `{ call(...args) }`.
|
|
@@ -706,8 +869,16 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
706
869
|
비교가 안 되므로 **존재만 비교**한다(값이 바뀌어도 no-op). 허위 diff(멱등 붕괴)보다
|
|
707
870
|
미검출(undershoot)이 안전하다는 원칙 — 값 변경이 필요하면 손작성 마이그로 `ALTER
|
|
708
871
|
COLUMN … SET DEFAULT` 를 쓴다. (기본값 **추가/제거**·스칼라(bool·정수·문자열·enum)
|
|
709
|
-
값 변경은 정상 감지된다.)
|
|
710
|
-
|
|
872
|
+
값 변경은 정상 감지된다.) **FK(belongsTo 의 REFERENCES)는 diff 축이 아니다** — FK 는
|
|
873
|
+
createTable/addColumn 시점에만 생성되므로, **기존 컬럼**을 `t.belongsTo()` 로 바꾸거나
|
|
874
|
+
belongsTo 를 떼도 컬럼 shape(bigint)가 같아 감지되지 않는다(FK 가 조용히 미생성/잔존).
|
|
875
|
+
기존 컬럼에 FK 를 걸려면 손작성 마이그로 `ADD CONSTRAINT … FOREIGN KEY` 를 쓴다.
|
|
876
|
+
이 제약·기본값 diff 는 postgres 커넥션 기준이다
|
|
877
|
+
(mysql=legacy §4.5 는 aux introspect 미지원 → 이 diff 생략). legacy(mysql/mariadb)
|
|
878
|
+
introspection 은 `char(n)`→uuid·`longtext`→jsonb **휴리스틱 매핑**을 쓴다(MariaDB 가
|
|
879
|
+
uuid/json 을 그 물리 타입으로 저장하는 왕복 정합 · 결정 273 Bug B) — Gaon 스키마가 만든
|
|
880
|
+
DB 에선 정확하지만, **기존(외부) DB 의 진짜 char/longtext 컬럼**은 uuid/jsonb 로 오인돼
|
|
881
|
+
허위 alterColumn diff 가 뜰 수 있다. 그런 컬럼은 스키마에 싣지 않거나 타입을 맞춘다.
|
|
711
882
|
- 마이그레이션은 커넥션별로 돈다(§7 · `--db <키>`).
|
|
712
883
|
|
|
713
884
|
### 10.1 시드 — `domain/seed.ts` · `seed()` (§7)
|
|
@@ -863,6 +1034,14 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
863
1034
|
안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
|
|
864
1035
|
다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
|
|
865
1036
|
그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
|
|
1037
|
+
- **벌크·upsert 는 `updatedAt` 을 자동 갱신하지 않는다** (결정 276 · 286 유보) —
|
|
1038
|
+
`updateAll()`/`incrementAll` 류와 `upsert()` 충돌-갱신은 명시한 컬럼만 쓴다. 자동
|
|
1039
|
+
갱신은 `rec.update()` 단건 전용. 벌크로 갱신 시각을 남기려면 patch 에
|
|
1040
|
+
`updatedAt: new Date()` 를 직접 넣는다.
|
|
1041
|
+
- **`.withCache()` 는 `Chain` 의 `all()`/`first()` 에만 적용된다** (결정 148 · §8.2) —
|
|
1042
|
+
뒤에 `include()`/`select()`/`distinct(col)`/`paginate()` 가 오면 캐시가 **조용히
|
|
1043
|
+
적용되지 않는다**(타입은 통과). include/select 없는 형태로 마감하거나
|
|
1044
|
+
`cache.remember` 로 감싼다.
|
|
866
1045
|
- **캐시를 "쓰면 자동으로 지워진다"고 기대하면 함정** (결정 148) — `cache`·`.withCache`
|
|
867
1046
|
는 **자동 무효화가 없다**. `create` 후에도 같은 `cache.remember`/`.withCache` 키는 TTL
|
|
868
1047
|
만료 전까지 stale 을 준다. 신선도가 중요하면 짧은 TTL 이나 `cache.forget(key)`. 자동
|
|
@@ -895,4 +1074,16 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
895
1074
|
| 결정 221 | 크로스 커넥션 트랜잭션 런타임 가드 — tx 안 다른 커넥션 **쓰기**(INSERT/UPDATE/DELETE) throw · **읽기는 예외** · 중첩 서비스/afterCommit/tx:false 는 자기 경계로 통과(§5 · 규칙 9) |
|
|
896
1075
|
| 결정 253 | hidden 마커를 **열거 가능한 심볼**로 부여 — `{ ...row }` spread·`Object.assign` 을 넘어 보존돼 우회 유출(S1) 봉합(§1) |
|
|
897
1076
|
| 결정 265 | 조인 시 자기 테이블 컬럼 자동 한정 — `where`/`whereAny`/`orderBy` 가 자기 테이블로 한정돼 ambiguous column 방지(§4 join) |
|
|
1077
|
+
| 결정 278 | 문서형 동사 `collection()`(Mongoose) — SQL `model()` 과 분리 · `mongoSchema()` 얇은 래퍼(판별 태그+hidden 전개) · `@gaonjs/adapter-mongo`(mongoose optional peer)(§7.1) |
|
|
1078
|
+
| 결정 283 | `select()`/`distinct(col)` 좁은 행에도 hidden 마커 부착 — 명시 select 한 hidden 컬럼의 조용한 직렬화 유출 봉합(§3 `.hidden()`) |
|
|
1079
|
+
| 결정 285 | nullable FK 의 belongsTo 관계는 `Row \| null` — 지연·include 모두 null 정규화(타입 green NPE 봉합 · §1.1) |
|
|
1080
|
+
| 결정 286 | `updateAll()` 도 mysql jsonb 직렬화(F3 확장) · 벌크 `updatedAt` 자동 갱신은 유보(미갱신 명기 · §3·함정) |
|
|
1081
|
+
| 결정 279 | 문서형 커넥션 `adapter: 'mongodb'`(config `db` 맵) · `isTableDef` 가 collection 제외(tables.d.ts·doctor) · `gaon db` mongo 마이그 fail-loud(§7.1) |
|
|
1082
|
+
| 결정 281 | 몽고 쓰기를 SQL `service()` tx 안에서 하면 `MongoCrossConnectionWriteError` 로 막힘 — "커밋 후 로그" 는 `afterCommit`(§7.1 · 결정 221 동형) |
|
|
1083
|
+
| 결정 282 | ObjectId(`_id` 포함) → string 직렬화 정규화(응답 경계 · 결정 37 문서형 대응 · `.lean()` 권장)(§7.1) |
|
|
1084
|
+
| 결정 288 | `mongoSchema()` 제네릭 = Mongoose Schema 생성자 복제 — 자작 시그니처의 문서 타입 조용한 붕괴(StringConstructor) 봉합 · 정본 = 명시 인터페이스(§7.1) |
|
|
1085
|
+
| 결정 289 | 문서 인스턴스 경로 hidden 마커 전파 — save/insertMany 훅 + toObject/toJSON transform + 응답 경계 문서→POJO 정규화(§7.1) |
|
|
1086
|
+
| 결정 290 | 중첩 경로 `hidden: true` 는 fail-loud throw — top-level 전용(조용한 유출 방지)(§7.1) |
|
|
1087
|
+
| 결정 291 | 쓰기 가드에 `insertOne`(mongoose 8.16+)·`bulkSave` 편입 — static 가드 갭 봉합(§7.1 · 결정 281 확장) |
|
|
1088
|
+
| 결정 292 | `gaon db` 전 커넥션 순회는 mongodb 를 skip + skipped 보고(exit 0) — `--db <mongo키>` 명시만 fail-loud(§7.1) |
|
|
898
1089
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -59,7 +59,8 @@ const props = pageProps<'web:posts#index'>()
|
|
|
59
59
|
는 매 접근마다 `usePage().props` 를 다시 읽는 **Proxy** 를 돌려준다. `const { csrf } =
|
|
60
60
|
useShared()` 처럼 구조분해하면 그 순간 값을 한 번 스냅샷해 **반응성이 끊긴다**(리다이렉트·
|
|
61
61
|
partial reload 후 flash·currentUser 갱신이 안 보인다). 항상 `const shared = useShared()`
|
|
62
|
-
로 받아 `shared.csrf`·`shared.flash` 로 접근한다.
|
|
62
|
+
로 받아 `shared.csrf`·`shared.flash` 로 접근한다. useShared 구조분해도 doctor
|
|
63
|
+
**pageprops-destructure** 가 잡는다(`.vue` + 앱 `.ts` 공통 · 결정 302).
|
|
63
64
|
- **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
|
|
64
65
|
로 import 하지 않는다.
|
|
65
66
|
- **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
|
|
@@ -120,8 +121,22 @@ async function search(q: string) {
|
|
|
120
121
|
(서버 `this.params` 우선순위와 대칭 · `agents/web.md` §3).
|
|
121
122
|
- **반환 타입** = 액션 반환의 `Serialized<>`. JSON 액션이 객체를
|
|
122
123
|
반환하면 그 타입 그대로, render 액션이면 render props 타입이 온다.
|
|
123
|
-
|
|
124
|
-
`
|
|
124
|
+
`this.json(data)` 액션은 **data 의 타입**이 온다(`{ json, status }` 래퍼가
|
|
125
|
+
아님 — 서버가 data 만 실어 보낸다 · `status` 는 HTTP 상태로만 · 결정 299).
|
|
126
|
+
액션이 조건부 `this.redirect(...)` 를 섞어도 render props 타입은 살아남는다
|
|
127
|
+
(유니온 멤버별 분배 · 결정 299).
|
|
128
|
+
- **실패** — 4xx/5xx 는 `ApiError` 로 던진다(결정 304). 캐스트 대신
|
|
129
|
+
`isApiError(e)` 로 좁혀 `e.status`·`e.body` 를 읽는다. 422(`ValidationError`)는
|
|
130
|
+
`e.body` 가 `ApiValidationBody`(`{ error, message, issues }`) 모양이다:
|
|
131
|
+
```ts
|
|
132
|
+
import { api, isApiError, type ApiValidationBody } from 'gaonjs/vue'
|
|
133
|
+
try { await api('web:posts#create', { title }) }
|
|
134
|
+
catch (e) {
|
|
135
|
+
if (isApiError(e) && e.status === 422) {
|
|
136
|
+
const { issues } = e.body as ApiValidationBody // field·source·이유
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
125
140
|
- **CSRF** — 세션 앱은 상태 변경 요청(POST/PUT/PATCH/DELETE)에 CSRF 토큰을
|
|
126
141
|
자동으로 `X-CSRF-Token` 헤더에 붙인다(손수 넘길 필요 없음 · 결정 166). 정본
|
|
127
142
|
출처는 data-page 의 `props.csrf` 공유 prop(결정 116 · `useForm({ _csrf: shared.csrf })`
|
|
@@ -417,10 +432,12 @@ async function runSearch(q: string) {
|
|
|
417
432
|
|
|
418
433
|
- **`usePage()`·`defineProps<T>()` 로 pageProps 대체 금지** — Serialized
|
|
419
434
|
경계 우회로 타입 안전 붕괴.
|
|
420
|
-
- **`pageProps()`·`useShared()` 는 setup
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
(
|
|
435
|
+
- **`pageProps()`·`useShared()` 는 `<script setup>` 최상위에서 한 번 잡는다** —
|
|
436
|
+
결정 300: usePage(@inertiajs/vue3 3.6)는 모듈 싱글턴이라 호출 자체는 어디서든
|
|
437
|
+
성립하지만(inject 아님), **앱 부팅(createGaonApp 마운트) 전에 속성에 접근**하면
|
|
438
|
+
페이지가 아직 없어 수리 안내 에러가 난다(모듈 최상위에서 잡는 실수의 실제 실패
|
|
439
|
+
모드). 관례는 setup 최상위에서 한 번 잡아(반응형 프록시라 그대로) 쓰는 것 —
|
|
440
|
+
이벤트 핸들러마다 다시 부를 필요도 없다.
|
|
424
441
|
- **`api()` 에 제네릭 인자 직접 붙이지 않는다** — key 리터럴이 타입을
|
|
425
442
|
결정한다.
|
|
426
443
|
- **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
|
|
@@ -446,7 +463,11 @@ async function runSearch(q: string) {
|
|
|
446
463
|
재구현하다 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에. 소켓이 끊기면
|
|
447
464
|
useChannel 이 지수 백오프로 **자동 재접속**하고 프레즌스를 재동기한다(결정 128 · 기본
|
|
448
465
|
켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401 은
|
|
449
|
-
재연결 안 함). 손 WebSocket 재연결 루프를 짜지 말 것.
|
|
466
|
+
재연결 안 함). 손 WebSocket 재연결 루프를 짜지 말 것. 결정 303: `send()` 는 소켓이
|
|
467
|
+
OPEN 이 아니면 보내지 않고 `false` 를 반환한다(큐잉 없음 — 유실 불가 송신은 반환값
|
|
468
|
+
확인). 컴포넌트 **밖**에서 부르면 즉시 접속되지만 자동 정리가 없어 호출자가
|
|
469
|
+
`close()` 를 책임진다(기본 배치는 setup 안). 오래 사는 채널은 `maxMessages` 로
|
|
470
|
+
`messages` 상한을 잡는다(`agents/realtime.md` §4).
|
|
450
471
|
- **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
|
|
451
472
|
중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
|
|
452
473
|
- **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
|
|
@@ -495,6 +516,13 @@ async function runSearch(q: string) {
|
|
|
495
516
|
| 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
|
|
496
517
|
| 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §7) |
|
|
497
518
|
| 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
|
|
519
|
+
| 결정 299 | 타입 브리지 PropsOf 정정 — 유니온 분배(조건부 redirect 혼합 액션의 never 붕괴 봉합) + `this.json(data)` 언랩(`{json,status}` 래퍼 타입 거짓 봉합 · `JsonResult<T>` 제네릭) (§2) |
|
|
520
|
+
| 결정 300 | pageProps/useShared 부팅 전 접근 가드(수리 안내) + setup-only 근거 정정(usePage=모듈 싱글턴 · inject 아님 · 실측) (§1·알려진 함정) |
|
|
521
|
+
| 결정 301 | `useShared()` Proxy 열거 트랩 정합 — 스프레드/Object.keys/JSON.stringify 빈 객체 봉합 · 코어 3종 `in` 정합(pageProps 4-트랩과 동일 계약) (§1) |
|
|
522
|
+
| 결정 302 | doctor pageprops-destructure 확장 — `useShared` 구조분해 + 앱 `.ts`(컴포저블) 순회(문서-검사 갭 봉합) (§1) |
|
|
523
|
+
| 결정 303 | `useChannel` 계약 3정비 — `send()` OPEN 아니면 `false` · 컴포넌트 밖 = 즉시 접속(정리는 호출자) · `maxMessages` 상한 (`agents/realtime.md` §4) |
|
|
524
|
+
| 결정 304 | `api()` 실패 타입 공개 — `ApiError`·`isApiError`·`ApiValidationBody`(422 issues 타입드) (§2) |
|
|
525
|
+
| 결정 305 | `createGaonApp` 마운트 대상(`#app`) 부재 = 수리 안내 throw(조용한 빈 화면 봉합) |
|
|
498
526
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
499
527
|
|
|
500
528
|
## `@gaonjs/seal` 켠 앱의 프론트
|
|
@@ -56,7 +56,8 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
|
|
|
56
56
|
|
|
57
57
|
`gaon dev` 의 compose 가 MailPit 을 띄운다(SMTP 캡처 + 웹 UI `:8025`) — 개발 중
|
|
58
58
|
보낸 메일은 실제로 나가지 않고 MailPit 수신함에서 확인한다. 스캐폴드 `gaon.config.ts`
|
|
59
|
-
·`.env.example` 에 mail 블록이 이미
|
|
59
|
+
·`.env.example` 에 mail 블록이 이미 있고 `.env` 는 `gaon new` 가 자동 생성하므로
|
|
60
|
+
(결정 160·198) 바로 돈다.
|
|
60
61
|
운영은 `SMTP_HOST`·자격증명·`SMTP_SECURE=true` 로 교체(같은 코드).
|
|
61
62
|
|
|
62
63
|
- **미리보기 정본 = MailPit** 이다 — 조용히 메모리로 삼키는 `captureTransport`(in-memory 싱크)는 **프레임웍 내부 테스트 전용**이라 파사드(`gaonjs/mail`)에 노출하지 않는다(결정 238 · 프로덕션 오배선 시 무신호 유실 방지).
|
|
@@ -86,7 +86,10 @@ export default channel({
|
|
|
86
86
|
- `onMessage` throw → 그 연결에 **에러 프레임(`{ t:'error' }`)을 보내고 `1011` 로 종료**한다
|
|
87
87
|
(요청 실패지만 서버는 생존 = HTTP 500 대칭).
|
|
88
88
|
- `onLeave` throw → 연결이 **이미 닫히는 중**이라 통지할 곳이 없다. **로그만 남기고 정리(프레즌스
|
|
89
|
-
|
|
89
|
+
해제·로컬 연결 회수)를 계속**한다(추가 종료·에러 프레임 없음 · 결정 309 — 정리는 finally 라
|
|
90
|
+
예외에도 누수가 없다).
|
|
91
|
+
- `onJoin`(또는 접속 직후 스냅샷 조회) throw → 연결은 join 실패(`1011`)로 닫히고, **이미 등록된
|
|
92
|
+
프레즌스는 자동 보상 해제**된다(결정 307) — 접속하지 못한 멤버가 로스터에 유령으로 남지 않는다.
|
|
90
93
|
|
|
91
94
|
재시도가 필요한 로직은 핸들러 안에서 try/catch 로 감싸 직접 통제한다.
|
|
92
95
|
|
|
@@ -208,7 +211,11 @@ export function useRoom(roomId: number) {
|
|
|
208
211
|
```
|
|
209
212
|
|
|
210
213
|
- **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
|
|
211
|
-
정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
|
|
214
|
+
정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다. **소켓이 OPEN 이 아니면
|
|
215
|
+
(접속 전·재연결 중) 보내지 않고 `false` 를 반환한다**(결정 303 · 큐잉 없음) — 유실이
|
|
216
|
+
안 되는 송신은 반환값을 확인해 재시도/안내한다.
|
|
217
|
+
- **메시지 상한** — `messages` 는 기본 무제한 축적이다. 채팅·대시보드처럼 오래 사는
|
|
218
|
+
채널은 `maxMessages: 200` 처럼 상한을 잡는다(초과분은 오래된 것부터 버림 · 결정 303).
|
|
212
219
|
- **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출. 접속자 프레임(초기
|
|
213
220
|
스냅샷 + 이후 들어옴/나감)은 하나의 **명단**으로 합쳐져 반응형 `members` 에 반영되고
|
|
214
221
|
`onPresence(members)` 로도 통지된다(결정 272 · 콜백만으로 항상 최신 명단 · 델타 병합
|
|
@@ -256,6 +263,14 @@ export function useRoom(roomId: number) {
|
|
|
256
263
|
스케줄러(고정 5s)는 서로 다른 KV 버킷(`gaon_lease_<역할>`)을 써 각자 TTL 을
|
|
257
264
|
가진다 — 한 버킷을 공유하던 시절의 TTL 플래핑(부팅 순서 의존)이 없다.
|
|
258
265
|
허브 TTL 을 튜닝해도 스케줄러 failover 타이밍에 영향을 주지 않는다.
|
|
266
|
+
- **멀티호스트는 `GAON_HUB_ADVERTISE` 필수.** KV 에 공지하는 도달 주소의 기본은
|
|
267
|
+
`127.0.0.1:<port>` 라 **단일 호스트 전용**이다 — 웹서버가 다른 호스트에 있으면
|
|
268
|
+
자기 localhost 로 붙으려다 무한 백오프에 빠진다. 웹서버가 도달 가능한 주소
|
|
269
|
+
(예: `hub.internal:4001`)를 `GAON_HUB_ADVERTISE` 로 준다(`docs/guides/operations.md`).
|
|
270
|
+
- **허브 TCP 포트는 내부망 전용이다(결정 311).** 허브 명령 채널에는 인증이 없다 —
|
|
271
|
+
포트(기본 4001)는 방화벽/보안그룹으로 웹서버 대역에만 연다. 프로토콜 위반
|
|
272
|
+
백스톱으로 라인 길이 상한(1MiB)을 두며, 초과 소켓은 즉시 끊는다(fail-closed ·
|
|
273
|
+
개행 없는 스트림의 메모리 증식 차단).
|
|
259
274
|
|
|
260
275
|
## 정본 예시
|
|
261
276
|
|
|
@@ -322,6 +337,11 @@ export default channel({
|
|
|
322
337
|
스냅샷과 별개로 받는다. `useChannel` 은 `members` 를 `id` 로 키잉해 이 중복을
|
|
323
338
|
**멱등**으로 흡수하므로 명단이 불어나지 않는다(결정 272). 직접 `new WebSocket`
|
|
324
339
|
을 쓰는 탈출구에서는 스스로 `id` 로 dedup 한다.
|
|
340
|
+
- **`useChannel` 을 컴포넌트 밖에서 부르면 자동 정리가 없다** — 컴포넌트 setup
|
|
341
|
+
안이면 마운트 접속·언마운트 정리가 자동이다. 밖(모듈 스코프 store 등)이면 즉시
|
|
342
|
+
접속은 되지만(결정 303 — 과거엔 라이프사이클 훅이 발화하지 않아 **영원히 closed**
|
|
343
|
+
인 무신호 미접속이었다) 언마운트 정리가 없으므로 **호출자가 `close()` 를 책임**진다.
|
|
344
|
+
기본 배치는 컴포저블 → 페이지/컴포넌트 setup 에서 호출.
|
|
325
345
|
- **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
|
|
326
346
|
(`agents/testing.md`).
|
|
327
347
|
|
|
@@ -340,6 +360,10 @@ export default channel({
|
|
|
340
360
|
| 결정 259 | 허브 디스커버리 endpoint 는 소유 리더만 삭제(§5) — addr 일치 + revision CAS · standby 종료·리더 교대가 활성 endpoint 를 지우지 않음(재접속 서버 허브 발견 보존) |
|
|
341
361
|
| 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
|
|
342
362
|
| 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
|
|
363
|
+
| 결정 303 | `useChannel` 계약 3정비(§4) — `send()` 는 OPEN 아니면 `false`(무신호 드롭 봉합 · 큐잉 없음) · 컴포넌트 밖 호출 = 즉시 접속(라이프사이클 훅 미발화로 영원히 closed 이던 무신호 미접속 봉합 · 정리는 호출자 `close()`) · `maxMessages` 상한 옵션(초과분 오래된 것부터 버림) |
|
|
364
|
+
| 결정 307 | `onJoin`/스냅샷 실패 = 프레즌스 보상 해제(§2) — join 후반 실패 시 이미 발신한 프레즌스 등록을 자동 회수(leave)·로컬 연결 정리 후 rethrow · 접속 못 한 멤버가 로스터에 유령으로 남던 결함 봉합 |
|
|
365
|
+
| 결정 309 | `onLeave` throw 에도 로컬 정리 계속(§2) — conns 회수·채널 teardown 을 finally 로 · "로그만 남기고 정리를 계속" 문서 계약과 코드 정합(conn·구독 누수 봉합) |
|
|
366
|
+
| 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 인증 없음 = 방화벽으로 내부망 한정 |
|
|
343
367
|
|
|
344
368
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
345
369
|
|
|
@@ -119,7 +119,10 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
|
119
119
|
- **fail-closed (403·413 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
120
120
|
**403 SealError** — 평문 통과 절대 없음. **과대 요청 본문(상한 초과 · `PAYLOAD_TOO_LARGE`)만 예외로 413**
|
|
121
121
|
(`readStream` OOM 방어 · `errors.ts`). WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
|
|
122
|
-
fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단
|
|
122
|
+
fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단 —
|
|
123
|
+
서버발 4500 도 close code 로 동일 판정 · 결정 318). 서버의 WS **에러 통지 프레임도 봉인**해 송신한다
|
|
124
|
+
(결정 318 — 평문 통지는 에러 문자열 wire 노출 + 클라 오도 "개봉 실패" 였다). 핸들러 실패(1011)는
|
|
125
|
+
transient 라 재연결한다 — 4500(봉인 계약 위반)만 종단.
|
|
123
126
|
|
|
124
127
|
### 3. CSP (결정 124)
|
|
125
128
|
|
|
@@ -204,6 +207,10 @@ export default controller({
|
|
|
204
207
|
4. **"seal 켰으니 검증 느슨" = 가짜 안심** — 서버 방어층(§0) 전부 유지. seal 은 이유가 못 된다.
|
|
205
208
|
5. **반쪽 봉인 금지** — "wire 전체 봉인" 기대. HTTP 만 봉인하고 WS 를 빼먹지 말 것(seal 앱 namespace 는 requireDecrypt).
|
|
206
209
|
6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
|
|
210
|
+
7. **클라이언트 시계 skew > 60초 = 그 사용자에게 앱 전체 403/4500** — 봉인 검증은 timestamp drift ±60s 를 강제한다(§4). 기기 시계가 어긋난 사용자는 모든 요청이 `drift` 403(WS 는 4500)으로 거부된다 — 서버 장애가 아니니 "기기 시계(자동 설정) 확인" 을 최종 사용자 안내에 포함하라.
|
|
211
|
+
8. **리버스 프록시의 Host 재작성 금지** — 서버 키 시드는 `Host` 헤더, 브라우저는 `location.hostname` 을 쓴다. 프록시가 Host 를 upstream 이름으로 바꾸면 키가 갈려 data-page 개봉 실패(blank)·전 요청 403 이 된다. 프록시는 원 Host 를 보존해야 한다(`proxy_set_header Host $host` 류 · `x-forwarded-host` 는 참조하지 않는다).
|
|
212
|
+
9. **쿼리 봉인은 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). 클라 인터셉터를 안 탄 인바운드(직접 URL 등)의 쿼리는 평문일 수 있다 — "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것.
|
|
213
|
+
10. **body 상한 ≈ 768KB** — 봉인 본문 상한은 1MiB 고정(base64 팽창 ×4/3 → 실효 평문 ≈768KB)이고 현재 프레임웍 배선은 이 값을 노출하지 않는다. 대용량 페이로드는 파일 스토리지(멀티파트는 body 봉인 예외 · §5.2) 경로로 우회하라.
|
|
207
214
|
|
|
208
215
|
## 관련 결정 번호
|
|
209
216
|
|
|
@@ -214,3 +221,4 @@ export default controller({
|
|
|
214
221
|
- **결정 223** — **HTTP replay Redis 없으면 조용히 off + 허위 주석 P1** 수정: `normalizeSealConfig` 이 nonceStore 없으면 `replay=null` 로 두어 nonce 검사가 사라지고 drift(±60s)만 남아 60초 내 재전송이 통과했다(`sealBridge` 주석은 "in-memory 폴백" 이라 거짓 단언 — `MemoryNonceStore` 는 export 만·미배선). HTTP replay 를 **항상 배선**한다 — Redis 있으면 재사용(멀티 인스턴스 안전), 없으면 in-memory 폴백(단일 인스턴스 전용) + 부팅 경고. **기각: 부팅 throw(옵션 A)** — 기본 배포가 워커 1(CLAUDE 규칙 6)이라 단일 인스턴스 in-memory 가 정상 경로인데 throw 는 dev·단일 인스턴스 seal 앱을 깨고 문서(§4 "in-memory 폴백 = 단일 인스턴스 전용")와 상충. 폴백+경고가 비파괴적·정본 정합.
|
|
215
222
|
- **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
|
|
216
223
|
- **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
|
|
224
|
+
- **결정 318** — **WS 에러 통지 프레임 codec 경유** 수정: 서버의 에러 통지(`{t:'error'}` · onMessage 실패 1011 / 개봉 실패 4500)가 codec 을 우회해 평문으로 나가 ① 에러 문자열 wire 평문 노출 ② 클라 wsDecode 의 오도성 "개봉 실패" ③ 일시적 1011 에도 seal 앱 채널만 영구 종료(비-seal 은 재연결)를 낳았다. 통지도 `codec.encode` 로 송신하고(encode 실패 시 통지 생략 — 평문 폴백 금지), `useChannel` 은 서버발 **4500 을 close code 로 직접 종단 판정**한다(결정 222 계약이 평문 프레임 부작용에 기대지 않게).
|
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
realtime 허용). 끄거나 조정은 `gaon.config.ts` 의 `web.security.securityHeaders` 로
|
|
33
33
|
— `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
|
|
34
34
|
`helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
|
|
35
|
+
- **rate limit 은 앱 라우트뿐 아니라 정적 에셋(`/assets/*`)·static 폴백까지 전
|
|
36
|
+
라우트에 적용된다**(기본 100 req/분/IP · 전역 등록). 에셋이 많은 페이지를 여러
|
|
37
|
+
사용자가 한 IP(NAT·사내망) 뒤에서 열면 기본치에 닿을 수 있다 — 그 경우
|
|
38
|
+
`web.security.rateLimit: { max: ... }` 로 상향한다(끄지 말고 조정).
|
|
39
|
+
- **기본 CSP 의 `connect-src 'self' ws: wss:` 는 스킴 와일드카드다** — realtime
|
|
40
|
+
기본 지원을 위해 임의 오리진 WebSocket 이 허용된다(XSS 성립 시 exfil 채널이
|
|
41
|
+
될 수 있는 트레이드오프). 더 조이려면 `web.security.securityHeaders.
|
|
42
|
+
contentSecurityPolicy` 로 자기 호스트(`wss://app.example.com`)만 명시한다.
|
|
35
43
|
- **스토리지 오리진 CSP 자동 배선 (결정 131)** — `gaon.config.ts` 의 storage(S3/R2/MinIO)
|
|
36
44
|
설정이 있으면 코어가 그 오리진을 CSP 의 **img-src**(스토리지 이미지 `<img>` 표시)·
|
|
37
45
|
**connect-src**(브라우저 직접 presigned 업로드/다운로드)에 자동으로 더한다(seal→
|
|
@@ -88,6 +96,13 @@
|
|
|
88
96
|
단일 출처)를 읽어 `X-CSRF-Token` 에 실어 준다(`packages/vue/src/api.ts`). data-page
|
|
89
97
|
가 없거나(비-Inertia) 봉인(seal)이면 레거시 `<meta name="csrf-token">` 로 폴백한다.
|
|
90
98
|
JWT/API 앱은 토큰 인증이라 CSRF 대상이 아니다.
|
|
99
|
+
- **알려진 한계**: 로그인(`this.auth.login`)은 세션을 재생성하므로(결정 254)
|
|
100
|
+
풀 리로드 없이 로그인한 직후에는 최초 문서의 data-page 토큰이 stale 이 돼
|
|
101
|
+
`api()` 상태 변경 호출이 403 이 날 수 있다(useForm 은 렌더마다 갱신되는
|
|
102
|
+
`shared.csrf` 를 쓰므로 무관). 로그인 성공 후 `api()` 변이를 이어가야 하면
|
|
103
|
+
풀 리로드(서버 redirect 는 SPA 네비게이션이라 불충분)를 거치거나 `useShared().csrf`
|
|
104
|
+
를 `opts.headers['X-CSRF-Token']` 으로 직접 넘긴다 — 근본 수정(라이브 페이지
|
|
105
|
+
props 우선 읽기)은 후속 결정.
|
|
91
106
|
- **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
|
|
92
107
|
세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
|
|
93
108
|
`app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
|
|
@@ -249,3 +264,6 @@ const rows = await Post.query()
|
|
|
249
264
|
| 결정 165 | 세션/CSRF 실패·415 를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 무 · 비-Inertia 는 JSON 유지(§2.3) |
|
|
250
265
|
| 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§2.3 · `this.auth`) |
|
|
251
266
|
| 결정 271 | CSRF 를 끄는 유일 스위치 = `session: { csrf: false }` — 생략은 켬(보안 기본값) · wire 가 `SessionOptions.csrf` 로 전달(§2.3) |
|
|
267
|
+
| 결정 295 | 세션 쿠키 `secure`/`sameSite` 를 app.config session 으로 조정(wire 전달) · `sameSite:'none'`+secure 미충족 = 부팅 에러(브라우저 조용한 쿠키 거부 방지) |
|
|
268
|
+
| 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw — 조용한 no-op(부팅 green·로그인 영구 실패) 금지 |
|
|
269
|
+
| 결정 297 | Inertia HTML/JSON 응답에 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — 공유 캐시가 사용자별 페이지(csrf·currentUser)를 저장·교차 서빙하지 못하게 |
|