@gaonjs/cli 0.52.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/check.d.ts +2 -0
- package/dist/commands/check.js +43 -1
- package/dist/commands/db.js +9 -0
- package/dist/commands/gen.d.ts +2 -0
- package/dist/commands/gen.js +3 -1
- package/dist/commands/new.js +13 -0
- package/dist/commands/test.js +28 -4
- package/dist/db/journal.d.ts +4 -1
- package/dist/db/journal.js +37 -4
- package/dist/db/migrate.js +11 -11
- package/dist/db/replay.js +1 -1
- package/dist/db/resolve.d.ts +11 -1
- package/dist/db/resolve.js +24 -2
- package/dist/db/status.js +9 -6
- package/dist/db.js +26 -5
- package/dist/dev.js +2 -2
- 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/locale-parity.js +2 -2
- 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/i18n-config.d.ts +20 -0
- package/dist/i18n-config.js +87 -12
- package/dist/index.js +111 -26
- package/dist/mcp/tools.js +4 -0
- package/dist/messages-gen.js +11 -3
- 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/Dockerfile.tpl +11 -1
- package/dist/templates/project/agents/async.md.tpl +56 -14
- package/dist/templates/project/agents/data.md.tpl +146 -35
- package/dist/templates/project/agents/frontend.md.tpl +24 -10
- package/dist/templates/project/agents/i18n.md.tpl +32 -4
- package/dist/templates/project/agents/mail.md.tpl +6 -0
- package/dist/templates/project/agents/realtime.md.tpl +115 -16
- package/dist/templates/project/agents/seal.md.tpl +13 -4
- package/dist/templates/project/agents/security.md.tpl +29 -4
- package/dist/templates/project/agents/storage.md.tpl +57 -13
- package/dist/templates/project/agents/testing.md.tpl +58 -0
- package/dist/templates/project/agents/web.md.tpl +171 -15
- package/dist/work.d.ts +3 -0
- package/dist/work.js +4 -0
- package/package.json +7 -7
|
@@ -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,17 +180,24 @@ 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` 으로 구분한다.
|
|
190
194
|
- 프레임웍이 만든 인덱스는 **`idx_` 접두**만 관리한다 — raw(수동 튜닝) 인덱스는 diff 가 건드리지
|
|
191
|
-
않으니(drop 계획 안 함), 손수 만든 특수 인덱스는 `idx_` 아닌 이름으로 둔다.
|
|
192
|
-
|
|
195
|
+
않으니(drop 계획 안 함), 손수 만든 특수 인덱스는 `idx_` 아닌 이름으로 둔다. 같은 이유로
|
|
196
|
+
**테이블 레벨 객체의 명시 `name` 은 `idx_` 접두 필수**다 — 접두 밖 이름은 introspection 에
|
|
197
|
+
안 잡혀 매 diff 마다 재생성 대상이 되므로 **정의 시점에 throw** 한다(결정 373). 인덱스
|
|
198
|
+
이름은 **커넥션(스키마) 전역 유일**이라 서로 다른 테이블의 같은 명시 name 도 diff 진입에서
|
|
199
|
+
throw 한다(결정 382).
|
|
200
|
+
- **MySQL/MariaDB(legacy · `adapter: 'mysql'` · §7) 커넥션은 gin/brin/gist·partial·표현식 인덱스가 없다** — 선언하면
|
|
193
201
|
`gaon db migrate` 가 **명확히 실패**한다(조용히 btree 로 떨구지 않음). 이런 인덱스는 main(postgres)에.
|
|
194
202
|
|
|
195
203
|
**선언적 파티셔닝** (결정 277 · PostgreSQL · 대용량 로그/이벤트/감사):
|
|
@@ -207,10 +215,24 @@ export const logs = table('logs', {
|
|
|
207
215
|
- 전략은 `range | list | hash`. **부모 테이블만 선언**한다 — 파티션 키는 PK 에 자동 편입된다
|
|
208
216
|
(복합 PK `(id, createdAt)`). 개별 **자식 파티션은 스키마 밖**(시간에 따라 증식)이라 헬퍼로 관리한다:
|
|
209
217
|
```ts
|
|
210
|
-
// domain/
|
|
211
|
-
import {
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
})
|
|
214
236
|
```
|
|
215
237
|
저수준 헬퍼: `createRangePartition`·`createListPartition`·`createHashPartition`·`createDefaultPartition`·
|
|
216
238
|
`dropPartition`·`listPartitions`. **retention(오래된 파티션 파기)은 절대 자동으로 하지 않는다** —
|
|
@@ -222,12 +244,13 @@ export const logs = table('logs', {
|
|
|
222
244
|
|
|
223
245
|
**`t.timestamps()` 와 `update()`** (결정 276): `update()` 는 `updatedAt` 을 **자동으로 현재 시각으로
|
|
224
246
|
갱신**한다(patch 에 `updatedAt` 을 직접 주면 그 값을 존중). 값 변경 없이 시각만 올리려면 `touch()`.
|
|
225
|
-
**벌크·upsert 도 자동 갱신한다**(결정 323 · 286 유보 해소) — `updateAll()
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
`toggle`
|
|
230
|
-
|
|
247
|
+
**벌크·upsert 도 자동 갱신한다**(결정 323 · 286 유보 해소) — `updateAll()` 과 `upsert()` 의
|
|
248
|
+
충돌-갱신 경로가 `updatedAt` 을 현재 시각으로 함께 갱신한다. "update 하면 updatedAt 이
|
|
249
|
+
바뀐다" 가 단건/벌크 구분 없이 하나다(The One Way). patch/update 에 `updatedAt` 을 명시하면
|
|
250
|
+
그 값을 존중한다. **원자 프리미티브는 단건/벌크 모두 대상 밖**(결정 375 — 323 이 벌크 증감만
|
|
251
|
+
포함하던 비대칭 해소): `rec.increment`/`decrement`/`toggle` 은 물론 `incrementAll`/
|
|
252
|
+
`decrementAll` 도 카운터·플래그 노이즈가 updatedAt 의 "콘텐츠 갱신" 신호를 덮지 않게
|
|
253
|
+
갱신하지 않는다(필요하면 `touch()` 병행).
|
|
231
254
|
|
|
232
255
|
### 4. 체이닝 전체 (`packages/data/src/model.ts`)
|
|
233
256
|
|
|
@@ -412,8 +435,36 @@ const rows = await Post.query()
|
|
|
412
435
|
|
|
413
436
|
혼동 유발이라 이름 분리를 유지한다 (E-4 (i) 결정).
|
|
414
437
|
|
|
415
|
-
### 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'
|
|
416
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).
|
|
417
468
|
- **키 생략 = main** — 기본 경로는 단일 DB 프로젝트와 완전히 같다.
|
|
418
469
|
- **커넥션을 가로지르는 `belongsTo`·역방향 관계는 금지** — SQL 조인은
|
|
419
470
|
커넥션을 못 넘는다. doctor 의 **schema-relations** 검사(결정 134)가
|
|
@@ -473,12 +524,20 @@ SQL `model()`(Kysely) 옆에 문서형 동사 `collection()` 을 **Mongoose**
|
|
|
473
524
|
로그·이벤트·감사·분석처럼 **문서·유연 스키마·대량 append** 용도다. **`model()` 은
|
|
474
525
|
SQL 전용 · `collection()` 은 문서형** — 한 동사가 두 세계를 처리하지 않는다(The One Way).
|
|
475
526
|
|
|
476
|
-
|
|
527
|
+
**추가로 설치할 것은 `mongoose` 하나뿐이다.** `@gaonjs/adapter-mongo` 는 파사드
|
|
528
|
+
`gaonjs` 의 정규 의존이라 이미 설치돼 있고(그래서 `gaonjs/data` 가 `collection`·
|
|
529
|
+
`mongoSchema` 를 재수출한다), 실제 드라이버인 `mongoose` 만 **optional peer** 라
|
|
530
|
+
SQL 전용 프로젝트는 받지 않는다 — 문서형을 쓰는 프로젝트만 명시 설치한다:
|
|
477
531
|
|
|
478
532
|
```bash
|
|
479
|
-
npm i
|
|
533
|
+
npm i mongoose
|
|
480
534
|
```
|
|
481
535
|
|
|
536
|
+
> `@gaonjs/adapter-mongo` 를 앱 의존으로 **따로 적지 말 것** — 파사드가 exact-pin 으로
|
|
537
|
+
> 끌고 오는 버전과 앱이 적은 range 가 갈리면 설치 트리에 어댑터가 둘로 갈라져
|
|
538
|
+
> (레지스트리 split) 커넥션 레지스트리가 서로 안 보인다. 설치 명령은 프로젝트의
|
|
539
|
+
> 패키지 매니저를 따른다(`pnpm add mongoose`·`yarn add mongoose` 동형).
|
|
540
|
+
|
|
482
541
|
```ts
|
|
483
542
|
// domain/schema/auditLog.ts — mongoSchema() 는 진짜 Mongoose Schema 를 반환한다.
|
|
484
543
|
// 정본 패턴(결정 288·332): Doc + Methods + Model 인터페이스를 선언하고 제네릭 3개를
|
|
@@ -567,13 +626,19 @@ export default defineConfig({
|
|
|
567
626
|
mongoose `autoIndex` 기본값이 첫 사용 시 인덱스를 만든다. **운영(NODE_ENV=production)
|
|
568
627
|
은 autoIndex 기본 false**(결정 333 · §8-3) — 부팅·첫 사용 시점의 암묵 인덱스 빌드가
|
|
569
628
|
없다. 운영 인덱스는 배포 절차에서 `syncMongoIndexes()`(gaonjs/data 재수출)로 명시
|
|
570
|
-
동기화한다(커넥션 키 지정 가능 · 스키마에 없는 인덱스는 드랍).
|
|
629
|
+
동기화한다(커넥션 키 지정 가능 · 스키마에 없는 인덱스는 드랍). `collection()`
|
|
630
|
+
정의는 자동 등록되므로 **아직 한 번도 안 쓴 컬렉션도 sync 가 강제 컴파일해
|
|
631
|
+
포함**한다(결정 365 — 이전엔 이미 쓴 모델만 순회해 부팅 직후 sync 가 무신호
|
|
632
|
+
no-op 였다 · 컴파일된 컬렉션이 0개면 경고). 커넥션 설정
|
|
571
633
|
`autoIndex: true/false` 명시가 항상 우선. 몽고는 마이그레이션이 없다 —
|
|
572
634
|
`gaon db diff/migrate`(키 생략 = 전 커넥션 순회)는 mongodb 커넥션을 **건너뛰고
|
|
573
635
|
skipped 로 보고**하며(결정 292), `--db <mongo키>` 명시 지정만 fail-loud 다.
|
|
574
636
|
- **문서형 커넥션은 DB 명이 필수** — `url` 에 경로(`mongodb://…/myapp`)를 넣거나
|
|
575
637
|
`database` 를 지정한다. 둘 다 없으면 부팅이 수리 안내로 throw 한다(결정 335 —
|
|
576
|
-
드라이버 기본 DB `test` 로 조용히 붙던 무신호 오배선 봉합).
|
|
638
|
+
드라이버 기본 DB `test` 로 조용히 붙던 무신호 오배선 봉합). **경로 없는 url**
|
|
639
|
+
(`mongodb://host:27017`·`mongodb+srv://cluster`·`…/?authSource=admin`)도 단독으로는
|
|
640
|
+
같은 이유로 throw 하며, `database` 를 병기하면 그 값이 `dbName` 으로 우선한다
|
|
641
|
+
(결정 364 — url 경로와 `database` 병기 시에도 `database` 가 이긴다).
|
|
577
642
|
|
|
578
643
|
**알려진 함정**:
|
|
579
644
|
- `aggregate` 결과는 임의 projection(그룹·계산)이라 `hidden` 마커를 심지 **않는다** —
|
|
@@ -584,7 +649,10 @@ export default defineConfig({
|
|
|
584
649
|
통과하고, **그 밖의 모든 static 호출은 fail-closed** 로 막힌다. Query 체이닝 쓰기
|
|
585
650
|
(`find(f).updateMany()`·`where(f).deleteMany()`)와 `aggregate` 의 `$out`/`$merge` 도
|
|
586
651
|
실행 지점 pre 훅이 막는다. **읽기 전용이어도 커스텀 static 은 tx 안에서 막힌다**
|
|
587
|
-
(쓰기 여부 판정 불가 — 내장 읽기를 직접 쓰거나 호출을 tx 밖으로).
|
|
652
|
+
(쓰기 여부 판정 불가 — 내장 읽기를 직접 쓰거나 호출을 tx 밖으로). **네이티브
|
|
653
|
+
탈출구 프로퍼티(`Model.collection`·`Model.db`·`Model.base`)도 tx 안 접근 자체가
|
|
654
|
+
막힌다**(결정 372 — 함수 가드를 우회해 `collection.insertOne` 무가드 쓰기가
|
|
655
|
+
가능하던 구멍 봉합). 유일한 예외는
|
|
588
656
|
인스턴스 `doc.save()`(정본 예외 · 미가드). tx 안에서 쓸 일이면 `afterCommit`.
|
|
589
657
|
- **같은 커넥션·같은 컬렉션명에 다른 스키마를 선언하면 첫 접근에서 throw**(결정 334)
|
|
590
658
|
— 조용히 첫 스키마를 재사용하지 않는다. 같은 컬렉션이면 mongoSchema 산출을 한
|
|
@@ -600,9 +668,15 @@ export default defineConfig({
|
|
|
600
668
|
깨져 있다(바닐라 동일 실측 · 지원 안 함).
|
|
601
669
|
- **중첩 hidden 은 v1.22(adapter-mongo 0.2.0) 이하에서 fail-loud throw 였다**(결정
|
|
602
670
|
290) — 0.3.0(결정 336)부터 dot-path 로 실지원된다. 지원 밖 위치(옵션 객체의 형제 키
|
|
603
|
-
아래 등)의 hidden 은 여전히 fail-loud(조용한 유출 금지).
|
|
604
|
-
(`mongoSchema` 산출을
|
|
605
|
-
|
|
671
|
+
아래 등)의 hidden 은 여전히 fail-loud(조용한 유출 금지). **서브스키마 인스턴스
|
|
672
|
+
(`mongoSchema` 산출을 필드/`type:`/배열에 임베드)의 내부 hidden 도 부모가 경로
|
|
673
|
+
접두로 승계한다**(결정 387 — 이전엔 미수집이라 `.lean()` 경로 무신호 유출이었다).
|
|
674
|
+
순정 `new mongoose.Schema` 임베드는 마커가 없어 승계 대상이 아니다 — hidden 이
|
|
675
|
+
필요한 서브스키마는 mongoSchema 로 만들라.
|
|
676
|
+
- **`schema.set('toObject', {...})` 는 hidden 마커 transform 을 통째로 대체한다**
|
|
677
|
+
(mongoose set 의 wholesale 교체) — 응답 경계가 문서 인스턴스 마커를 상속 스레딩해
|
|
678
|
+
hidden 유출은 막지만(결정 371 방어), virtuals 등 옵션이 필요하면 mongoSchema 의
|
|
679
|
+
`toObject` **옵션 인자로** 주는 쪽이 안전하다(transform 이 체이닝된다 · 결정 289).
|
|
606
680
|
|
|
607
681
|
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
|
|
608
682
|
|
|
@@ -778,7 +852,9 @@ const top = await Post.published().latest().limit(5).withCache(30).all()
|
|
|
778
852
|
- **적용 지점은 `Chain` 의 `all()`/`first()` 뿐이다** — `.withCache()` 뒤에
|
|
779
853
|
`include()`/`select()`/`distinct(col)`/`groupBy()`/`withCount()`/`join()`/`paginate()`
|
|
780
854
|
가 오면 캐시가 적용될 수 없어 **즉시 throw** 한다(수리 안내 포함 · 결정 330 — 이전엔
|
|
781
|
-
조용히 미적용).
|
|
855
|
+
조용히 미적용). **종단 집계(`count()`/`exists()`/`sum()`/`avg()`/`min()`/`max()`/
|
|
856
|
+
`pluck()`)와 벌크 쓰기(`updateAll()` 류)도 같은 이유로 throw** 한다(결정 370 · 330
|
|
857
|
+
완결). 캐시가 필요하면 include/select 없는 형태로 `all()`/`first()` 마감하거나,
|
|
782
858
|
결과 조립 전체를 `cache.remember` 로 감싼다.
|
|
783
859
|
- **키는 호출자가 정한다** — 로케일·사용자 등 변이 축은 **키에 직접 넣는다**
|
|
784
860
|
(`` `page:${locale}` ``). 프레임웍이 변이 축을 자동으로 섞지 않는다.
|
|
@@ -908,7 +984,10 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
908
984
|
migrate 가 자동 적용**한다(예: `email` 에 `.unique()` 추가 → `ADD CONSTRAINT
|
|
909
985
|
… UNIQUE` 생성). **제거(제약·인덱스·기본값을 스키마에서 뗌)는 자동 적용하지
|
|
910
986
|
않고** dropTable 처럼 크게 알린다 — 제거는 `gaon db diff` 의 down SQL 을 보고
|
|
911
|
-
손작성 마이그로 적용한다(수동/외부 제약 보호 · 결정 39 정합).
|
|
987
|
+
손작성 마이그로 적용한다(수동/외부 제약 보호 · 결정 39 정합). 단 **재생성
|
|
988
|
+
쌍**(인덱스 method 변경·enum 값 변경이 내는 같은 이름의 drop→add)은 제거가
|
|
989
|
+
아니라 교체이므로 migrate 가 **한 단위로 자동 적용**한다(결정 366 — 이전엔
|
|
990
|
+
drop 반쪽만 걸러져 add 가 "already exists" 로 매번 실패했다). **한계**: 임의
|
|
912
991
|
`.check(expr)` 의 **표현식만** 바꾸는 변경(같은 컬럼·같은 제약, 식만 수정)은
|
|
913
992
|
Postgres 가 표현식을 정규화해 신뢰 비교가 불가능하므로 **감지하지 못한다** —
|
|
914
993
|
이 경우 컬럼을 갈거나 손작성 마이그로 CHECK 를 drop→add 한다. (enum 값 변경과
|
|
@@ -919,12 +998,13 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
919
998
|
미검출(undershoot)이 안전하다는 원칙 — 값 변경이 필요하면 손작성 마이그로 `ALTER
|
|
920
999
|
COLUMN … SET DEFAULT` 를 쓴다. (기본값 **추가/제거**·스칼라(bool·정수·문자열·enum)
|
|
921
1000
|
값 변경은 정상 감지된다.) **FK(belongsTo 의 REFERENCES)는 diff 축이 아니다** — FK 는
|
|
922
|
-
createTable/addColumn 시점에만 생성되므로(mysql 도 addColumn 이 FK 를
|
|
1001
|
+
createTable/addColumn 시점에만 생성되므로(mysql 도 addColumn 이 FK 를 결정적 이름
|
|
1002
|
+
`fk_<table>_<col>` 로 동반하고, `migrate down` 롤백이 그 FK 를 선-drop 한다 · 결정 327·369),
|
|
923
1003
|
**기존 컬럼**을 `t.belongsTo()` 로 바꾸거나
|
|
924
1004
|
belongsTo 를 떼도 컬럼 shape(bigint)가 같아 감지되지 않는다(FK 가 조용히 미생성/잔존).
|
|
925
1005
|
기존 컬럼에 FK 를 걸려면 손작성 마이그로 `ADD CONSTRAINT … FOREIGN KEY` 를 쓴다.
|
|
926
1006
|
이 제약·기본값 diff 는 postgres 커넥션 기준이다
|
|
927
|
-
(mysql=legacy §
|
|
1007
|
+
(mysql=legacy §7 은 aux introspect 미지원 → 이 diff 생략). legacy(mysql/mariadb)
|
|
928
1008
|
introspection 은 `char(n)`→uuid·`longtext`→jsonb **휴리스틱 매핑**을 쓴다(MariaDB 가
|
|
929
1009
|
uuid/json 을 그 물리 타입으로 저장하는 왕복 정합 · 결정 273 Bug B) — Gaon 스키마가 만든
|
|
930
1010
|
DB 에선 정확하지만, **기존(외부) DB 의 진짜 char/longtext 컬럼**은 uuid/jsonb 로 오인돼
|
|
@@ -938,7 +1018,9 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
938
1018
|
|
|
939
1019
|
- **시그니처**: `seed(fn: () => Promise<void> | void): SeedDef` — `gaonjs/data`
|
|
940
1020
|
에서 import 한다. 본문(`fn`)은 **모델을 그대로** 쓴다 — 모델이 커넥션을 자동
|
|
941
|
-
바인딩하므로(§
|
|
1021
|
+
바인딩하므로(§7) 시드는 커넥션을 몰라도 된다. `gaon db seed` 는 선언된
|
|
1022
|
+
**전 SQL 커넥션을 등록하고 시드를 1회 실행**한다(결정 367 — 여러 커넥션의
|
|
1023
|
+
모델을 한 시드에서 섞어 써도 된다 · 문서형(mongodb) 커넥션은 열지 않는다).
|
|
942
1024
|
- **멱등하게 짠다** — 시드는 재적재에 자주 쓰이므로 여러 번 돌려도 안전해야
|
|
943
1025
|
한다(예: `upsert`/존재 확인 후 생성).
|
|
944
1026
|
- `gaon db seed` 는 모델 레이어를 거치므로 **프로젝트 로컬로 실행**하는 것이
|
|
@@ -1084,13 +1166,18 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
1084
1166
|
안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
|
|
1085
1167
|
다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
|
|
1086
1168
|
그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
|
|
1087
|
-
-
|
|
1088
|
-
`updateAll`/`
|
|
1089
|
-
`
|
|
1090
|
-
병행하거나 patch 로 갱신한다.
|
|
1091
|
-
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1169
|
+
- **원자 프리미티브(단건·벌크)는 `updatedAt` 을 갱신하지 않는다** (결정 276·323·375) —
|
|
1170
|
+
`update`/`updateAll`/`upsert` 충돌-갱신은 자동 갱신하지만, `rec.increment`/`decrement`/
|
|
1171
|
+
`toggle` 과 `incrementAll`/`decrementAll` 은 카운터 축이라 대상 밖이다. 갱신 시각까지
|
|
1172
|
+
남기려면 `touch()` 를 병행하거나 patch 로 갱신한다.
|
|
1173
|
+
- **hidden 컬럼을 `pluck()`/`groupBy()` 로 뽑은 값은 render props 에 싣지 말 것** (결정 379) —
|
|
1174
|
+
스칼라 배열·그룹 행은 런타임 마커(HIDDEN_COLUMNS)를 실을 수 없어 serializeProps 가 못
|
|
1175
|
+
떨군다(결정 283 은 행 단위). 타입 축은 `Serialized` 가 그 값을 **never** 로 접어 신호한다 —
|
|
1176
|
+
서버측 내부 검증·비교 용도로만 쓴다.
|
|
1177
|
+
- **`.withCache()` 는 `Chain` 의 `all()`/`first()` 에만 적용된다** (결정 148·330·370 · §8.2) —
|
|
1178
|
+
뒤에 `include()`/`select()`/`distinct(col)`/`paginate()` 류 전이는 물론 **종단 집계
|
|
1179
|
+
(`count()`/`pluck()` 등)·벌크 쓰기(`updateAll()` 류)도 즉시 throw** 한다(조용한
|
|
1180
|
+
미적용 대신 수리 안내). include/select 없는 형태로 마감하거나
|
|
1094
1181
|
`cache.remember` 로 감싼다.
|
|
1095
1182
|
- **캐시를 "쓰면 자동으로 지워진다"고 기대하면 함정** (결정 148) — `cache`·`.withCache`
|
|
1096
1183
|
는 **자동 무효화가 없다**. `create` 후에도 같은 `cache.remember`/`.withCache` 키는 TTL
|
|
@@ -1130,12 +1217,36 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
1130
1217
|
| 결정 286 | `updateAll()` 도 mysql jsonb 직렬화(F3 확장) · 벌크 `updatedAt` 자동 갱신 유보 → **결정 323 으로 해소** |
|
|
1131
1218
|
| 결정 321 | 기존 테이블 `partitionBy` 변경은 diff/migrate 즉시 throw(조용한 no-op 제거 · 손작성 이관 안내 · §3) |
|
|
1132
1219
|
| 결정 322 | PK 컬럼 키 `id` 강제 — `model()` 정의 시점 수리 안내 throw(§1) |
|
|
1133
|
-
| 결정 323 | `updateAll`·`
|
|
1220
|
+
| 결정 323 | `updateAll`·`upsert` 충돌-갱신도 `updatedAt` 자동 갱신 — 벌크 증감 포함은 **결정 375 로 제외 재결정**(§3) |
|
|
1134
1221
|
| 결정 325 | `batchInsert` 파라미터 상한(65,535) 초과 자동 분할 — 전체 한 트랜잭션(원자성 유지 · §4) |
|
|
1135
1222
|
| 결정 327 | mysql `addColumn` 이 belongsTo FK 를 같은 ALTER 문으로 동반(§10) |
|
|
1136
1223
|
| 결정 328 | `distinct(col).paginate` 의 total = 선택 컬럼 distinct 축(§4) |
|
|
1137
1224
|
| 결정 329 | 인덱스 이름 충돌(같은 컬럼 method/partial 중복) 정의 시점 throw — `name` 으로 구분(§3) |
|
|
1138
1225
|
| 결정 330 | `.withCache()` 무효 전이(include/select/paginate 류) 즉시 throw(§8.2·함정) |
|
|
1226
|
+
| 결정 364 | 문서형 url 에 DB 경로 없으면 throw · url+`database` 병기 시 `database`(dbName) 우선(335 완결 · §7.1) |
|
|
1227
|
+
| 결정 365 | `collection()` 정의 자동 등록 — `syncMongoIndexes()` 가 미사용 컬렉션도 강제 컴파일 후 동기화(§7.1) |
|
|
1228
|
+
| 결정 366 | 재생성 쌍(인덱스 method·enum 값 변경의 같은 이름 drop→add)은 defer 예외 — migrate 가 한 단위 자동 적용(§10) |
|
|
1229
|
+
| 결정 367 | `gaon db seed` = 전 SQL 커넥션 등록 + 1회 실행 — 멀티커넥션 시드의 '미등록' 실패 봉합(§10.1) |
|
|
1230
|
+
| 결정 368 | 캐시 코덱 bytea(Uint8Array·Buffer) 태그 왕복 — `.withCache` 히트의 조용한 값 손상 봉합(§8.2) |
|
|
1231
|
+
| 결정 369 | mysql `addColumn` FK 결정적 이름(`fk_<table>_<col>`) + `migrate down` 이 FK 선-drop(327 의 down 대칭 · §10) |
|
|
1232
|
+
| 결정 370 | `.withCache()` 뒤 종단 집계(count/pluck 류)·벌크 쓰기도 throw(330 완결 · §8.2·함정) |
|
|
1233
|
+
| 결정 371 | 응답 경계가 문서 인스턴스 마커를 상속 스레딩 — 사용자 `schema.set('toObject')` transform 대체에도 hidden 유출 없음(§7.1 함정) |
|
|
1234
|
+
| 결정 372 | 네이티브 탈출구 프로퍼티(`Model.collection`/`db`/`base`)도 tx 안 접근 fail-closed(331 완결 · §7.1) |
|
|
1235
|
+
| 결정 373 | 테이블 레벨 명시 인덱스 `name` 은 `idx_` 접두 필수 — 정의 시점 throw(329 완결 · §3) |
|
|
1236
|
+
| 결정 374 | `gaon db migrate` 가 applyStatements 경유 — 비호환 타입 변경 실패에 USING 수리 안내(F5 정본 경로 배선 · §10) |
|
|
1237
|
+
| 결정 375 | 원자 증감은 단건/벌크 모두 `updatedAt` 대상 밖(323 비대칭 해소 — 카운터 축 · §3) |
|
|
1238
|
+
| 결정 376 | 몽고 allowlist 읽기 4종(applyVirtuals·applyTimestamps·createSearchIndexes·clientEncryption) 편입 · 죽은 count 제거(§7.1) |
|
|
1239
|
+
| 결정 377 | mysql MODIFY 가 대상 기본값을 재선언 — 기존 DEFAULT 조용한 소거 봉합(§10) |
|
|
1240
|
+
| 결정 378 | eager hasOne 빈 값 = undefined 통일(타입 계약 정합 · §6) |
|
|
1241
|
+
| 결정 379 | 객체 키 밖 hidden 브랜드 값(pluck/groupBy hiddenCol)은 `Serialized` 가 never 로 신호(§4 함정) |
|
|
1242
|
+
| 결정 380 | journal applied_at 프로세스 내 단조 증가 + mysql 원장 timestamp(6)·mediumtext 승격 — 롤백 대상 오선정·64KB 절단 봉합(§10) |
|
|
1243
|
+
| 결정 381 | 파티션 헬퍼 스키마 한정(to_regclass·자식 스키마 반환·한정 drop) + 월 bound UTC 명시(§3) |
|
|
1244
|
+
| 결정 382 | 인덱스 이름 교차 테이블 전역 유일 검사 — diff 진입 fail-loud(329·373 완결 · §3) |
|
|
1245
|
+
| 결정 383 | DDL 식별자 인용 `"` 이스케이프 위생(§10) |
|
|
1246
|
+
| 결정 384 | partitionBy 불일치·mongo 키 안내문 방언/실동작 정정(321·333 문구 · §3·§7.1) |
|
|
1247
|
+
| 결정 385 | 단일 값 enum CHECK(`= 'a'::text` 정규화형)도 값 diff — 1개→N개 확장 감지(§10) |
|
|
1248
|
+
| 결정 386 | collection() Proxy 함수 identity 캐시 — `Coll.on === Coll.on`(off 해제 패턴 성립 · §7.1) |
|
|
1249
|
+
| 결정 387 | 서브스키마 임베드 내부 hidden 을 부모가 경로 접두로 승계 — `.lean()` 무신호 유출 봉합(§7.1) |
|
|
1139
1250
|
| 결정 279 | 문서형 커넥션 `adapter: 'mongodb'`(config `db` 맵) · `isTableDef` 가 collection 제외(tables.d.ts·doctor) · `gaon db` mongo 마이그 fail-loud(§7.1) |
|
|
1140
1251
|
| 결정 281 | 몽고 쓰기를 SQL `service()` tx 안에서 하면 `MongoCrossConnectionWriteError` 로 막힘 — "커밋 후 로그" 는 `afterCommit`(§7.1 · 결정 221 동형) |
|
|
1141
1252
|
| 결정 282 | ObjectId(`_id` 포함) → string 직렬화 정규화(응답 경계 · 결정 37 문서형 대응 · `.lean()` 권장)(§7.1) |
|
|
@@ -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).
|
|
@@ -143,7 +149,10 @@ async function search(q: string) {
|
|
|
143
149
|
출처 · `packages/vue/src/csrf.ts`) — 로그인(세션 재생성 · 결정 254) 후에도 항상
|
|
144
150
|
최신이다. 최초 문서 data-page 와 `<meta name="csrf-token">` 은 부팅 전 폴백.
|
|
145
151
|
`useForm`/`router` 제출도 같은 자동 부착을 받는다(결정 342 — 페이지가 `_csrf`
|
|
146
|
-
바디·수동 헤더를 싣지 않는다 · `agents/security.md`).
|
|
152
|
+
바디·수동 헤더를 싣지 않는다 · `agents/security.md`). **명시 헤더는 존중된다
|
|
153
|
+
(탈출구 · 결정 388)**: `api(key, params, { headers: { 'X-CSRF-Token': ... } })` 나
|
|
154
|
+
`useForm().post(url, { headers: ... })` 로 토큰을 직접 실으면(대소문자 무관)
|
|
155
|
+
자동 부착이 그 값을 덮지 않는다.
|
|
147
156
|
- **파라미터 배열(결정 343)** — `api('web:posts#index', { tags: ['a', 'b'] })` 처럼
|
|
148
157
|
배열 값을 넘길 수 있다. GET 은 중복 키(`tags=a&tags=b` · 서버 `this.params` 의
|
|
149
158
|
"중복 키 = 배열" 규칙과 대칭), 본문은 문자열화된 JSON 배열로 실린다. `:param`
|
|
@@ -354,7 +363,7 @@ import PageShell from '@shared/components/ui/PageShell.vue'
|
|
|
354
363
|
```vue
|
|
355
364
|
<script setup lang="ts">
|
|
356
365
|
import Pagination from '@shared/components/ui/Pagination.vue'
|
|
357
|
-
import { router } from 'gaonjs/vue'
|
|
366
|
+
import { pageProps, router } from 'gaonjs/vue'
|
|
358
367
|
const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
|
|
359
368
|
function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
|
|
360
369
|
</script>
|
|
@@ -411,22 +420,24 @@ if (env.dev) console.log(env.mode) // 내장: dev·prod·mode·baseUrl(camelCa
|
|
|
411
420
|
## 정본 예시
|
|
412
421
|
|
|
413
422
|
```vue
|
|
414
|
-
<!-- apps/web/pages/Posts/Index.vue — pageProps + api + string id key -->
|
|
423
|
+
<!-- apps/web/pages/Posts/Index.vue — Head + pageProps + api + string id key -->
|
|
415
424
|
<script setup lang="ts">
|
|
416
425
|
import { ref } from 'vue'
|
|
417
|
-
import { pageProps, api } from 'gaonjs/vue'
|
|
426
|
+
import { Head, pageProps, api } from 'gaonjs/vue'
|
|
418
427
|
import PostCard from '../../components/PostCard.vue'
|
|
419
428
|
|
|
420
429
|
const props = pageProps<'web:posts#index'>()
|
|
421
430
|
const results = ref<Awaited<ReturnType<typeof runSearch>>>([])
|
|
422
431
|
|
|
423
432
|
async function runSearch(q: string) {
|
|
424
|
-
const res = await api('web:posts#search', { q })
|
|
433
|
+
const res = await api('web:posts#search', { q }) // 라우트 키는 '<app>:<ctrl>#<action>'
|
|
425
434
|
return res.results
|
|
426
435
|
}
|
|
427
436
|
</script>
|
|
428
437
|
|
|
429
438
|
<template>
|
|
439
|
+
<!-- 문서 <title> 은 Head 로만 — document.title 수동 조작 금지(결정 271 · §1) -->
|
|
440
|
+
<Head title="글 목록" />
|
|
430
441
|
<div>
|
|
431
442
|
<!-- 컨트롤러가 String(p.id) 정규화 → :key 에 그대로 (결정 37) -->
|
|
432
443
|
<PostCard v-for="post in props.posts" :key="post.id" :title="post.title" />
|
|
@@ -468,12 +479,14 @@ async function runSearch(q: string) {
|
|
|
468
479
|
URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클·**자동 재연결**을
|
|
469
480
|
재구현하다 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에. 소켓이 끊기면
|
|
470
481
|
useChannel 이 지수 백오프로 **자동 재접속**하고 프레즌스를 재동기한다(결정 128 · 기본
|
|
471
|
-
켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 ·
|
|
472
|
-
재연결 안 함). 손 WebSocket 재연결
|
|
482
|
+
켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 · 종단 close code
|
|
483
|
+
**4401**(authorize 거부)·**4500**(seal 개봉 실패)만 재연결 안 함). 손 WebSocket 재연결
|
|
484
|
+
루프를 짜지 말 것. 결정 303: `send()` 는 소켓이
|
|
473
485
|
OPEN 이 아니면 보내지 않고 `false` 를 반환한다(큐잉 없음 — 유실 불가 송신은 반환값
|
|
474
486
|
확인). 컴포넌트 **밖**에서 부르면 즉시 접속되지만 자동 정리가 없어 호출자가
|
|
475
487
|
`close()` 를 책임진다(기본 배치는 setup 안). 오래 사는 채널은 `maxMessages` 로
|
|
476
|
-
`messages` 상한을
|
|
488
|
+
`messages` 상한을 잡는다. 접속자 목록(`members`)은 **드롭·종료에 비워지지 않으므로**
|
|
489
|
+
`status` 를 함께 봐서 렌더한다(옵션·콜백 전체 표는 `agents/realtime.md` §4).
|
|
477
490
|
- **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
|
|
478
491
|
중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
|
|
479
492
|
- **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
|
|
@@ -520,7 +533,7 @@ async function runSearch(q: string) {
|
|
|
520
533
|
| 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
|
|
521
534
|
| 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
|
|
522
535
|
| 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
|
|
523
|
-
| 결정 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) |
|
|
524
537
|
| 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
|
|
525
538
|
| 결정 299 | 타입 브리지 PropsOf 정정 — 유니온 분배(조건부 redirect 혼합 액션의 never 붕괴 봉합) + `this.json(data)` 언랩(`{json,status}` 래퍼 타입 거짓 봉합 · `JsonResult<T>` 제네릭) (§2) |
|
|
526
539
|
| 결정 300 | pageProps/useShared 부팅 전 접근 가드(수리 안내) + setup-only 근거 정정(usePage=모듈 싱글턴 · inject 아님 · 실측) (§1·알려진 함정) |
|
|
@@ -534,6 +547,7 @@ async function runSearch(q: string) {
|
|
|
534
547
|
| 결정 343 | `ApiParams` 배열 지원 — GET 중복 키·본문 JSON 배열(서버 중복 키=배열 규칙 대칭) · `:param` 은 스칼라만(§2) |
|
|
535
548
|
| 결정 344 | `useChannel` 함수형 `params` — 접속·재접속 시점마다 평가(JWT access_token 회전 반영 · `agents/realtime.md` §4) |
|
|
536
549
|
| 결정 345 | `PageMap` 항목 타입 의도 문서화(`PageMapEntry` — 지연 로더·eager 모듈 · vite glob unknown 제약 명기) |
|
|
550
|
+
| 결정 388 | `api()` 명시 `X-CSRF-Token` 헤더 존중(대소문자 무관 · 인터셉터와 대칭 — 덮어쓰기·콤마 병합 403 봉합) · `:param` 누락 에러 예시를 라우트 키 형태로 정정(§2) |
|
|
537
551
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
538
552
|
|
|
539
553
|
## `@gaonjs/seal` 켠 앱의 프론트
|
|
@@ -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
|
|
|
@@ -237,6 +250,18 @@ export const SendDigest = job(async ({ userId, locale }: { userId: bigint; local
|
|
|
237
250
|
|
|
238
251
|
## 알려진 함정
|
|
239
252
|
|
|
253
|
+
- **`i18n.dir`·`fallbackLng` 는 문자열 리터럴로 쓴다**(결정 412) — 변수 참조·env 표현식은
|
|
254
|
+
타입 축(`.gaon/messages.d.ts`)과 doctor 가 정적으로 못 읽어 기본값(`locales` · 정렬 첫
|
|
255
|
+
로케일)으로 떨어진다. 못 읽으면 `gaon gen`·`dev`·`check` 가 한 줄 경고를 낸다(무소음 아님).
|
|
256
|
+
치환 없는 템플릿 리터럴(`` `locales` ``)·`satisfies`/`as` 래핑은 읽는다.
|
|
257
|
+
- **절대 경로 `dir` 도 지원된다**(결정 412) — 런타임과 CLI 축이 같은 규약으로 해석한다.
|
|
258
|
+
- **`gaon check` 가 부팅 조건을 먼저 본다**(결정 413) — 카탈로그가 비었거나
|
|
259
|
+
`fallbackLng`/`supportedLngs` 가 지목한 파일이 없으면 check 가 실패한다(종전엔 check 는
|
|
260
|
+
통과하고 `gaon serve` 만 죽었다 · 결정 353).
|
|
261
|
+
- **`supportedLngs: []`(빈 배열)은 "미지정" 과 같다**(결정 414) — 종전엔 협상이 조용히
|
|
262
|
+
꺼져 항상 fallback 이 나갔다. 실제로 제한하려면 언어를 채우고, 제한이 없으면 키를 뺀다.
|
|
263
|
+
|
|
264
|
+
|
|
240
265
|
- **`i18n` 설정이 없으면 `t()` 는 항상 fallback** — 요청별 로케일 협상은
|
|
241
266
|
`gaon.config.ts` 에 `i18n` 이 있어야 배선된다(결정 159). 설정만 하면 자동.
|
|
242
267
|
- **키를 손으로 `string` 으로 넓히지 말 것** — `.gaon/messages.d.ts`(결정 158)가
|
|
@@ -267,3 +292,6 @@ export const SendDigest = job(async ({ userId, locale }: { userId: bigint; local
|
|
|
267
292
|
| 결정 216 (13차 W4) | `locale-parity` doctor 경고(§7) — 로케일 간 키 부분 누락 = fallback 조용 노출 · 기준 로케일 유니온의 사각 |
|
|
268
293
|
| 결정 352 | messages.d.ts 기준 로케일 = `fallbackLng`(정적 분석 전달) · `i18n.dir` 을 타입 축(gen/dev/check)·locale-parity 가 존중(하드코딩 'locales' 제거) |
|
|
269
294
|
| 결정 353 | 부팅 fail-loud — i18n 설정 + 빈 카탈로그(전 화면 raw 키 방지) · `fallbackLng`/`supportedLngs` 가 지목한 로케일 파일 통째 부재(조용한 fallback 언어 대체 방지) 는 부팅 에러 + 수리 안내 |
|
|
295
|
+
| 결정 412 | i18n config 정적 분석 범위 확대(템플릿 리터럴·satisfies/as·shorthand) + **미해석 경고**(무소음 제거) · 절대 경로 `dir` 을 런타임과 같은 규약으로 해석 |
|
|
296
|
+
| 결정 413 | 결정 353(부팅 fail-loud) 조건을 `gaon check` 가 정적으로 미리 검사(check green → serve 크래시 사각 제거) |
|
|
297
|
+
| 결정 414 | messages 축을 gen/check 보고에 포함 · 생성 키 이스케이프 · stale `messages.d.ts` 정리 · `supportedLngs: []` 를 미지정과 일원화 · 협상 언어 순서 결정론화 |
|
|
@@ -116,6 +116,10 @@ export const SendWelcome = job(async (userId: bigint) => {
|
|
|
116
116
|
으로 뺀다(`agents/async.md` 판단표 · doctor `async-offload`).
|
|
117
117
|
- **메일 로케일 ≠ 요청 로케일** — 메일은 요청과 다른 컨텍스트(잡)에서 나갈 수 있다.
|
|
118
118
|
`deliver(data, { locale: recipient.locale })` 로 **명시**한다(결정 160).
|
|
119
|
+
- **`secure: true`(465)면 `requireTls` 는 무시된다** — implicit TLS 라 STARTTLS 협상이
|
|
120
|
+
없다(부팅 경고 · 결정 404). STARTTLS 강제가 목적이면 `secure` 를 끄고 587 + `requireTls`.
|
|
121
|
+
- **발송 로그의 수신자는 마스킹된다**(`h***@example.com` · 결정 404) — 원문 주소가 필요하면
|
|
122
|
+
앱이 자기 감사 로그에 남긴다(프레임웍 로그는 PII 저장소가 아니다).
|
|
119
123
|
- **from 이 없으면 발송 에러** — 메시지에 `from` 을 주거나 `configureMailer({ defaultFrom })`
|
|
120
124
|
(스캐폴드 config 의 `MAIL_FROM`)를 설정한다.
|
|
121
125
|
- **레이아웃 상속은 v1 에 없다** — 공통 레이아웃은 v1.1 백로그(결정 160). v1 은 각
|
|
@@ -127,3 +131,5 @@ export const SendWelcome = job(async (userId: bigint) => {
|
|
|
127
131
|
|---|---|
|
|
128
132
|
| §7 (v0.15) | 메일 배터리 · `domain/mails/` · MailPit dev sink |
|
|
129
133
|
| 결정 160 (13차 W3) | `deliver(data, { locale, to })` 로케일 인지 · 스캐폴드 mail 블록 · 레이아웃 v1.1 백로그 |
|
|
134
|
+
| 결정 357 | SMTP user/pass 반쪽 설정 부팅 fail-loud · `requireTls`(STARTTLS 강제) 표면 |
|
|
135
|
+
| 결정 404 | `secure: true` + `requireTls` 무의미 조합 부팅 경고 · 발송 로그 수신자 마스킹 |
|