@gaonjs/cli 0.42.2 → 0.43.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 +5 -5
- package/dist/db/reset.js +20 -10
- package/dist/db/resolve.js +7 -0
- package/dist/doctor/schema-relations.js +6 -1
- package/dist/mcp/tools.js +15 -1
- package/dist/templates/project/AGENTS.md.tpl +4 -3
- package/dist/templates/project/agents/async.md.tpl +6 -3
- package/dist/templates/project/agents/data.md.tpl +145 -17
- package/dist/templates/project/agents/frontend.md.tpl +18 -3
- package/dist/templates/project/agents/i18n.md.tpl +3 -1
- package/dist/templates/project/agents/mail.md.tpl +6 -4
- package/dist/templates/project/agents/realtime.md.tpl +11 -7
- package/dist/templates/project/agents/seal.md.tpl +35 -7
- package/dist/templates/project/agents/security.md.tpl +8 -0
- package/dist/templates/project/agents/storage.md.tpl +23 -6
- package/dist/templates/project/agents/testing.md.tpl +11 -2
- package/dist/templates/project/agents/web.md.tpl +11 -3
- package/package.json +5 -5
- package/dist/doctor/shared-composable-purity.d.ts +0 -8
- package/dist/doctor/shared-composable-purity.js +0 -164
package/dist/commands/test.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* @gaonjs/cli · `gaon test` — vitest wrapper (M9-G · v0.15 §13.5)
|
|
3
3
|
*
|
|
4
4
|
* The One Way — 하나의 명령이 vitest 를 얇게 감싼다. 사용자는
|
|
5
|
-
* `gaon test` 로 전체를, `gaon test posts` 로 필터를, `gaon test --unit`
|
|
5
|
+
* `gaon test` 로 전체를, `gaon test posts` 로 필터를, `gaon test --scope unit`
|
|
6
6
|
* 으로 단위만 실행한다. 나머지는 전부 vitest 에 그대로 위임 — 우리가
|
|
7
7
|
* 관례를 재발명하지 않는다.
|
|
8
8
|
*
|
|
9
|
-
* 스코프 필터(§9 실 인프라 관례):
|
|
10
|
-
* --unit *.test.ts (통합 제외)
|
|
11
|
-
* --integration *.integration.test.ts 만
|
|
12
|
-
* (기본)
|
|
9
|
+
* 스코프 필터(§9 실 인프라 관례 · `--scope <값>`):
|
|
10
|
+
* --scope unit *.test.ts (통합 제외)
|
|
11
|
+
* --scope integration *.integration.test.ts 만
|
|
12
|
+
* --scope all / (기본) 둘 다 실행
|
|
13
13
|
*
|
|
14
14
|
* 실행 경로 우선순위:
|
|
15
15
|
* 1) 사용자 package.json 의 `test` 스크립트가 있으면 `<pm> run test`(결정 170 ·
|
package/dist/db/reset.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { sql } from 'kysely';
|
|
13
|
-
import { snapshotFromDb, MIGRATIONS_TABLE } from '@gaonjs/data';
|
|
13
|
+
import { snapshotFromDb, orderTableDropsByFk, MIGRATIONS_TABLE } from '@gaonjs/data';
|
|
14
14
|
import { resolveDbTarget } from './resolve.js';
|
|
15
15
|
import { runDbMigrate } from './migrate.js';
|
|
16
16
|
import { runDbSeedCommand } from '../db.js';
|
|
@@ -71,7 +71,8 @@ export async function runDbReset(opts) {
|
|
|
71
71
|
// 현재 DB 스냅샷 — 지울 테이블 목록. dialect.introspect 는 이미
|
|
72
72
|
// _gaon_migrations 를 제외하지만, reset 은 이력도 지워야 하므로 별도 처리.
|
|
73
73
|
const snapshot = await snapshotFromDb(target.db, target.dialect);
|
|
74
|
-
|
|
74
|
+
// FK 역순(자식 먼저)으로 정렬해 mysql(CASCADE 없음) 다중 drop 이 FK 위반 없이 돌게 한다(F4 · 결정 275).
|
|
75
|
+
const userTables = orderTableDropsByFk(snapshot, Object.keys(snapshot).sort());
|
|
75
76
|
// dry-run: 무엇을 지울지·만들지를 계산해 출력. 실제 파괴는 안 함.
|
|
76
77
|
if (opts.dryRun) {
|
|
77
78
|
const dropSqls = userTables.map((t) => target.dialect.dropTable(t));
|
|
@@ -93,15 +94,24 @@ export async function runDbReset(opts) {
|
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
else {
|
|
96
|
-
// DROP ALL — 이력 테이블도 함께.
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
// DROP ALL — 이력 테이블도 함께. pg 는 dropTable 이 CASCADE, mysql 은 CASCADE 가 없어
|
|
98
|
+
// FK 역순 정렬(위 orderTableDropsByFk)로 안전하게 지운다. 순환 FK 방어로 mysql 은
|
|
99
|
+
// FOREIGN_KEY_CHECKS 를 잠시 끈다(F4 · 결정 275).
|
|
100
|
+
const isMysql = target.dialect.name === 'mysql';
|
|
101
|
+
if (isMysql)
|
|
102
|
+
await sql.raw('set foreign_key_checks = 0').execute(target.db);
|
|
103
|
+
try {
|
|
104
|
+
for (const t of userTables) {
|
|
105
|
+
await sql.raw(target.dialect.dropTable(t)).execute(target.db);
|
|
106
|
+
droppedTables.push(t);
|
|
107
|
+
}
|
|
108
|
+
// 이력 테이블 삭제 — 존재하지 않아도 에러 없게.
|
|
109
|
+
await sql.raw(`drop table if exists ${MIGRATIONS_TABLE}`).execute(target.db);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
if (isMysql)
|
|
113
|
+
await sql.raw('set foreign_key_checks = 1').execute(target.db);
|
|
102
114
|
}
|
|
103
|
-
// 이력 테이블 삭제 — 존재하지 않아도 에러 없게.
|
|
104
|
-
await sql.raw(`drop table if exists ${MIGRATIONS_TABLE}`).execute(target.db);
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
finally {
|
package/dist/db/resolve.js
CHANGED
|
@@ -128,6 +128,13 @@ export async function resolveDbTarget(opts) {
|
|
|
128
128
|
` 등록된 키: ${known.length ? known.join(', ') : '(없음)'}\n` +
|
|
129
129
|
` → gaon.config.ts 의 db.${dbKey} 를 선언하고 다시 실행하세요.`);
|
|
130
130
|
}
|
|
131
|
+
// 결정 279(§7.4): 문서형(mongodb) 커넥션은 마이그레이션이 없다 — SQL 전용 경로(diff/migrate/
|
|
132
|
+
// introspect)로 흘리지 않고 명확히 안내한다. 몽고 인덱스는 Mongoose 스키마 선언으로 관리한다.
|
|
133
|
+
if (connCfg.adapter === 'mongodb') {
|
|
134
|
+
throw new Error(`[gaon db] 커넥션 '${dbKey}' 은 문서형(mongodb)입니다 — 문서형은 마이그레이션이 없습니다(§7.4).\n` +
|
|
135
|
+
` → 몽고 인덱스는 Mongoose 스키마 선언(index:true / schema.index())으로 관리합니다(dev 자동 ensureIndexes).\n` +
|
|
136
|
+
` → gaon db diff/migrate 는 SQL 커넥션(postgres·mysql)에만 실행하세요.`);
|
|
137
|
+
}
|
|
131
138
|
const db = createDb(connCfg);
|
|
132
139
|
registerConnection(dbKey, db, connCfg.adapter);
|
|
133
140
|
const dialect = dialectFor(connCfg.adapter);
|
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
// belongsTo 를 검출할 수 있다(resolve.ts scanTables 는 단일 dbKey 로 거르므로 부적합).
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
import { join, relative } from 'node:path';
|
|
16
|
-
import { scanSchemaDir, checkCrossConnectionRelations, checkRelationTargets, } from '@gaonjs/data';
|
|
16
|
+
import { scanSchemaDir, checkCrossConnectionRelations, checkRelationTargets, checkPartitions, } from '@gaonjs/data';
|
|
17
17
|
import { registerTsResolve } from '../tsResolve.js';
|
|
18
18
|
/** TableDef 판별 — scanSchemaDir 이 준 모듈 네임스페이스에서 table() 산출만 고른다. */
|
|
19
19
|
function isTableDef(v) {
|
|
20
20
|
return (typeof v === 'object' &&
|
|
21
21
|
v !== null &&
|
|
22
|
+
// 결정 279(§4.3·§6.2): 문서형 collection() 산출(__gaonKind)은 크로스커넥션 관계 로드에서
|
|
23
|
+
// 제외한다 — SQL↔문서형 belongsTo/hasMany 는 애초에 관계 그래프에 오르지 않는다(추가 코드
|
|
24
|
+
// 없이 격리 · generator.ts isTableDef 와 동일 판별).
|
|
25
|
+
!('__gaonKind' in v) &&
|
|
22
26
|
'name' in v &&
|
|
23
27
|
'defs' in v &&
|
|
24
28
|
'db' in v &&
|
|
@@ -108,6 +112,7 @@ export async function checkSchemaRelations(cwd) {
|
|
|
108
112
|
const diags = [
|
|
109
113
|
...checkCrossConnectionRelations(tables),
|
|
110
114
|
...checkRelationTargets(tables),
|
|
115
|
+
...checkPartitions(tables), // 결정 277 — 파티션 키 컬럼 존재 검사
|
|
111
116
|
];
|
|
112
117
|
const issues = diags.map((d) => toCheck(d, fileOf, ownerOf(d.message)));
|
|
113
118
|
return { rule: 'schema-relations', issues };
|
package/dist/mcp/tools.js
CHANGED
|
@@ -201,6 +201,7 @@ export async function getSchemaTool(args, cwd) {
|
|
|
201
201
|
index: t.constraints.index,
|
|
202
202
|
check: t.constraints.check,
|
|
203
203
|
},
|
|
204
|
+
partitionBy: t.partitionBy ? { strategy: t.partitionBy.strategy, columns: [...t.partitionBy.columns] } : undefined,
|
|
204
205
|
}));
|
|
205
206
|
const lines = [];
|
|
206
207
|
if (tables.length === 0) {
|
|
@@ -231,10 +232,23 @@ export async function getSchemaTool(args, cwd) {
|
|
|
231
232
|
const chk = t.constraints.check ?? [];
|
|
232
233
|
if (uniq.length > 0)
|
|
233
234
|
lines.push(` unique: ${uniq.map((u) => `[${u.join(', ')}]`).join(', ')}`);
|
|
235
|
+
// 인덱스 원소는 문자열 배열(btree) 또는 method/partial/표현식 객체(결정 273).
|
|
234
236
|
if (idx.length > 0)
|
|
235
|
-
lines.push(` index: ${idx
|
|
237
|
+
lines.push(` index: ${idx
|
|
238
|
+
.map((u) => {
|
|
239
|
+
if (Array.isArray(u))
|
|
240
|
+
return `[${u.join(', ')}]`;
|
|
241
|
+
const o = u;
|
|
242
|
+
const target = o.expr ?? `[${(o.cols ?? []).join(', ')}]`;
|
|
243
|
+
const usingSfx = o.using ? ` using ${o.using}` : '';
|
|
244
|
+
const whereSfx = o.where ? ` where ${o.where}` : '';
|
|
245
|
+
return `${target}${usingSfx}${whereSfx}`;
|
|
246
|
+
})
|
|
247
|
+
.join(', ')}`);
|
|
236
248
|
if (chk.length > 0)
|
|
237
249
|
lines.push(` check: ${chk.map(([n]) => n).join(', ')}`);
|
|
250
|
+
if (t.partitionBy)
|
|
251
|
+
lines.push(` partition: ${t.partitionBy.strategy} (${t.partitionBy.columns.join(', ')})`);
|
|
238
252
|
}
|
|
239
253
|
lines.push('');
|
|
240
254
|
lines.push(`총 ${tables.length}개 테이블`);
|
|
@@ -115,7 +115,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
115
115
|
|
|
116
116
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
117
117
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
118
|
-
3. `dependency-direction` — 의존 방향 4규칙 위반
|
|
118
|
+
3. `dependency-direction` — 의존 방향 4규칙 위반 (domain→shared 를 값으로 import 한 경우만 `--fix` 지원 = `import`→`import type` AST 삽입 · 나머지 방향(domain→app·app→app·shared→app)은 파일 위치 판단이 필요해 수동 · `doctor/fixers/dependency-direction`)
|
|
119
119
|
4. `connections` — 스키마·`getConnection` 이 쓰는 커넥션 키가 `gaon.config.ts` 에 등록됐는지 · db 설정 정적 분석(삼항·`??` 지원 · 못 읽으면 안내) (§4.5 · 결정 135)
|
|
120
120
|
5. `migration-diff` — 스키마 vs DB 상태 불일치
|
|
121
121
|
6. `shared-purity` — shared/ 전체(.ts·.vue)가 `api`/`pageProps`·domain 값을 import (컴포저블·컴포넌트 통일 · 결정 25·217)
|
|
@@ -137,7 +137,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
137
137
|
22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
|
|
138
138
|
23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
|
|
139
139
|
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon doctor --fix --yes` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
140
|
-
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
|
|
140
|
+
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). **파티션 키 컬럼이 실제 컬럼인지도 검사**(결정 277 · `checkPartitions` — 오타·유령 컬럼). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`·`checkPartitions`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134·277 · `agents/data.md`)
|
|
141
141
|
26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
|
|
142
142
|
27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
|
|
143
143
|
|
|
@@ -210,7 +210,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
|
|
|
210
210
|
| `gaon new <name>` | 프로젝트 스캐폴드 |
|
|
211
211
|
| `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**serve·work·hub 자동 기동**·**코드 변경 감시·재시작** · 결정 211) |
|
|
212
212
|
| `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
|
|
213
|
-
| `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` |
|
|
213
|
+
| `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` · `g auth --app <앱> --public` = 비-web 앱에 공개 회원가입(`/registration/new`)을 opt-in(기본: web=공개·비-web=역할 게이트 · 결정 155) |
|
|
214
214
|
| `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
|
|
215
215
|
| `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
|
|
216
216
|
| `gaon check` / `test` / `doctor` | 검증 루프 |
|
|
@@ -242,6 +242,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
|
|
|
242
242
|
| 패키지 | 역할 |
|
|
243
243
|
|---|---|
|
|
244
244
|
| `gaonjs` | 파사드(설치 단위) · CLI `gaon` |
|
|
245
|
+
| `create-gaon` | `npm create gaon <name>` 진입점 · `gaon new` 에 위임 |
|
|
245
246
|
| `@gaonjs/cli` | 제너레이터·스캐폴딩·명령 라우팅 |
|
|
246
247
|
| `@gaonjs/data` | 스키마 DSL · 모델 · 마이그레이션 |
|
|
247
248
|
| `@gaonjs/config` | `gaon.config.ts`·`app.config.ts` |
|
|
@@ -79,9 +79,12 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
|
|
|
79
79
|
- **시그니처** — `job(handler, options?)`. 첫 인자는 평범한 async
|
|
80
80
|
함수(`(...args) => Promise<void> | void`) — `defineJob` 이나
|
|
81
81
|
`{ perform }` 객체 형태가 아니다.
|
|
82
|
-
- **이름** — `
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
- **이름** — `job()` 이 **정의 시점에 스스로** 이름을 잡는다: `options.name` 이
|
|
83
|
+
있으면 그것, 없으면 자신을 정의한 **파일의 파일명**을 스택에서 유추한다
|
|
84
|
+
(`inferNameFromCaller`). 그래서 `domain/jobs/` 파일에 두기만 하면 로더를 기다리지
|
|
85
|
+
않고 곧바로 `.later()` 를 호출할 수 있다. `domain/jobs/` 파일 로더의 `assignName`
|
|
86
|
+
은 폴백일 뿐(이미 이름이 있으면 멱등). 스택에서 파일명을 못 얻는 특수 환경에서만
|
|
87
|
+
`options.name` 을 명시한다(이름을 못 얻은 채 발행하면 에러).
|
|
85
88
|
- **파일당 잡 하나(결정 271)** — 같은 파일에 `job()` 을 둘 이상 두면 파일명
|
|
86
89
|
유추가 충돌한다. 이제 **등록 시 throw**(조용한 덮어쓰기 = 발행이 엉뚱한
|
|
87
90
|
핸들러로 가던 무신호 버그 봉합) — 각각 `name` 을 다르게 주거나 파일을 나눈다.
|
|
@@ -147,24 +147,71 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
147
147
|
— 단 레코드를 손으로 재구성하거나 JSON 왕복하면 마커가 사라지니 그럴 땐 hidden
|
|
148
148
|
컬럼을 직접 넣지 않는다.
|
|
149
149
|
- `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
|
|
150
|
-
- `.index()` — 컬럼 레벨 인덱스 (E-4).
|
|
150
|
+
- `.index()` — 컬럼 레벨 인덱스 (E-4). 옵션 객체로 method·partial 지정 (결정 273):
|
|
151
|
+
- `.index()` — 기본 인덱스. **jsonb 컬럼은 자동 gin**(유연 검색 `@>`·`?` 용 · The One Way), 그 외는 btree.
|
|
152
|
+
- `.index({ using: 'brin' })` — 시계열 정렬 컬럼(예: `createdAt`)에 관례로 BRIN.
|
|
153
|
+
- `.index({ where: "status = 'active'" })` — partial(부분) 인덱스.
|
|
154
|
+
- method 집합 = `btree | gin | brin | gist`. **jsonb→gin 자동은 컬럼 `.index()` 한 곳뿐** —
|
|
155
|
+
brin/gist 는 명시(과유도가 perpetual-diff 를 늘림). **`t.jsonb()` 도 `.index()` 를 가진다**(결정 273 신설).
|
|
151
156
|
- `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4).
|
|
152
157
|
|
|
153
|
-
**테이블 레벨 복합 제약** (E-4 §4.2):
|
|
158
|
+
**테이블 레벨 복합 제약** (E-4 §4.2 · 인덱스 객체 확장 결정 273):
|
|
154
159
|
|
|
155
160
|
```ts
|
|
156
|
-
export const
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
export const logs = table('logs', {
|
|
162
|
+
userId: t.belongsTo('users'),
|
|
163
|
+
status: t.enum(['active', 'archived'] as const),
|
|
164
|
+
createdAt: t.datetime(),
|
|
165
|
+
meta: t.jsonb<Record<string, unknown>>().index(), // 자동 gin
|
|
159
166
|
...t.timestamps(),
|
|
160
167
|
}, {
|
|
161
|
-
db: 'main',
|
|
162
|
-
unique: [['
|
|
163
|
-
index: [
|
|
164
|
-
|
|
168
|
+
db: 'main', // 커넥션 키 (§7)
|
|
169
|
+
unique: [['userId', 'status']], // 복합 unique
|
|
170
|
+
index: [
|
|
171
|
+
['userId', 'createdAt'], // 복합 btree(문자열 배열 = 기존과 동일)
|
|
172
|
+
{ cols: ['meta'], using: 'gin' }, // jsonb GIN
|
|
173
|
+
{ cols: ['createdAt'], using: 'brin' }, // 시계열 BRIN
|
|
174
|
+
{ cols: ['status'], where: "status = 'active'" }, // partial
|
|
175
|
+
{ expr: "(meta->>'tenant')" }, // 표현식 인덱스(이름 자동 · 해시)
|
|
176
|
+
],
|
|
177
|
+
check: [['positive_age', 'age > 0']], // [name, expr]
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- 인덱스 원소가 **문자열 배열**이면 기존과 100% 동일(btree). **객체**면 `cols`·`expr`·`using`·`where`·`name`.
|
|
182
|
+
- 프레임웍이 만든 인덱스는 **`idx_` 접두**만 관리한다 — raw(수동 튜닝) 인덱스는 diff 가 건드리지
|
|
183
|
+
않으니(drop 계획 안 함), 손수 만든 특수 인덱스는 `idx_` 아닌 이름으로 둔다.
|
|
184
|
+
- **MySQL/MariaDB(legacy §4.5) 커넥션은 gin/brin/gist·partial·표현식 인덱스가 없다** — 선언하면
|
|
185
|
+
`gaon db migrate` 가 **명확히 실패**한다(조용히 btree 로 떨구지 않음). 이런 인덱스는 main(postgres)에.
|
|
186
|
+
|
|
187
|
+
**선언적 파티셔닝** (결정 277 · PostgreSQL · 대용량 로그/이벤트/감사):
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
export const logs = table('logs', {
|
|
191
|
+
id: t.id(),
|
|
192
|
+
createdAt: t.datetime(),
|
|
193
|
+
meta: t.jsonb<Record<string, unknown>>().index(), // 자동 gin
|
|
194
|
+
}, {
|
|
195
|
+
partitionBy: { strategy: 'range', columns: ['createdAt'] }, // 부모만 선언
|
|
165
196
|
})
|
|
166
197
|
```
|
|
167
198
|
|
|
199
|
+
- 전략은 `range | list | hash`. **부모 테이블만 선언**한다 — 파티션 키는 PK 에 자동 편입된다
|
|
200
|
+
(복합 PK `(id, createdAt)`). 개별 **자식 파티션은 스키마 밖**(시간에 따라 증식)이라 헬퍼로 관리한다:
|
|
201
|
+
```ts
|
|
202
|
+
// domain/schedule.ts — 크론 잡으로 명시 실행(자동 마법 없음)
|
|
203
|
+
import { rollMonthlyPartitions } from 'gaonjs/data'
|
|
204
|
+
await rollMonthlyPartitions(db, { table: 'logs', ahead: 1, keep: 6 })
|
|
205
|
+
// ahead=다가올 개월 미리 생성 · keep=6 이면 6개월 지난 파티션 파기(keep 없으면 파기 안 함)
|
|
206
|
+
```
|
|
207
|
+
저수준 헬퍼: `createRangePartition`·`createListPartition`·`createHashPartition`·`createDefaultPartition`·
|
|
208
|
+
`dropPartition`·`listPartitions`. **retention(오래된 파티션 파기)은 절대 자동으로 하지 않는다** —
|
|
209
|
+
`keep` 을 명시하고 그 잡을 실행할 때만 지운다(결정 39 자동 DROP 금지 정신).
|
|
210
|
+
- MySQL/MariaDB 커넥션은 선언적 파티셔닝을 지원하지 않는다(migrate 시 fail-loud).
|
|
211
|
+
|
|
212
|
+
**`t.timestamps()` 와 `update()`** (결정 276): `update()` 는 `updatedAt` 을 **자동으로 현재 시각으로
|
|
213
|
+
갱신**한다(patch 에 `updatedAt` 을 직접 주면 그 값을 존중). 값 변경 없이 시각만 올리려면 `touch()`.
|
|
214
|
+
|
|
168
215
|
### 4. 체이닝 전체 (`packages/data/src/model.ts`)
|
|
169
216
|
|
|
170
217
|
체이닝 표면은 아래 표가 **전부**다. 표에 없는 메서드
|
|
@@ -185,7 +232,7 @@ export const posts = table('posts', {
|
|
|
185
232
|
| 메서드 | 시그니처 | 반환 | 비고 |
|
|
186
233
|
|---|---|---|---|
|
|
187
234
|
| `where` | `(col, op, val?)` | `Chain` | op 에 따라 val 형태 강제 (위 표) |
|
|
188
|
-
| `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)`
|
|
235
|
+
| `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)` 축약. **빈 배열(`[]`)은 단락**(결정 99) — 단독이면 DB 무접촉으로 빈 결과(`count`=0·`exists`=false), 다른 조건과 `or` 로 섞이면 `1 = 0` 으로 상수 폴딩(`in ()` 문법 오류 방지) |
|
|
189
236
|
| `whereAny` | `(cols, op, val?)` | `Chain` | **여러 컬럼에 같은 조건을 OR 로 묶어 괄호로 감쌈** = `(c1 op v OR c2 op v)` · 앞선 where 와 **AND 로 안전 결합**(결정 118). 다중 컬럼 검색의 정본 — `orWhere` 로 흩뜨리면 앞 조건이 샌다(아래 함정) |
|
|
190
237
|
| `orWhere` | `(col, op, val?)` | `Chain` | op 12종 전부 (M2C) · 결합은 `(a AND b) OR c` (Rails 관습). **다중 컬럼 검색엔 쓰지 말 것** — `whereAny` 를 쓴다(결정 118) |
|
|
191
238
|
| `orderBy` | `(col, dir?)` | `Chain` | dir 기본 `'asc'` · 호출마다 누적 (다중 정렬) · 정렬 뒤 PK 타이브레이커 자동 부가(결정 110) |
|
|
@@ -203,9 +250,9 @@ export const posts = table('posts', {
|
|
|
203
250
|
| `pluck` | `(col)` | `Promise<Row[col][]>` | 단일 컬럼 배열 · 정렬·limit·offset 반영 |
|
|
204
251
|
| `select` | `(['a', 'b'])` | `SelectChain<Row, K>` | 부분 컬럼 — `first`/`all` 이 `Pick<Row, K>` **plain 행** 반환 (메서드·관계·update 없음) |
|
|
205
252
|
| `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
|
|
206
|
-
| `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
|
|
253
|
+
| `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy·**distinct** 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
|
|
207
254
|
| `deleteAll` | `()` | `Promise<number>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
|
|
208
|
-
| `incrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 증가**(결정 115) — `SET col = col + by` 한 문장 · 수치 컬럼 · 영향 행 수(number). 벌크 계약(limit/offset/orderBy 있으면 throw)은 updateAll 과 동일 |
|
|
255
|
+
| `incrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 증가**(결정 115) — `SET col = col + by` 한 문장 · 수치 컬럼 · 영향 행 수(number). 벌크 계약(limit/offset/orderBy/distinct 있으면 throw)은 updateAll 과 동일 |
|
|
209
256
|
| `decrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 감소**(결정 115) — `SET col = col - by`. incrementAll 의 대칭 |
|
|
210
257
|
|
|
211
258
|
**집계·조인 그룹** (`Chain` · M2E · 결정 34):
|
|
@@ -214,7 +261,7 @@ export const posts = table('posts', {
|
|
|
214
261
|
|---|---|---|---|
|
|
215
262
|
| `groupBy` | `(col \| col[])` | `GroupChain` | 그룹 집계로 **분기** — 종단은 집계 함수 하나(`count`/`sum`/`avg`/`min`/`max`)이고 결과는 `Rec[]` 이 아니라 **`그룹 키 + 집계값` 행 배열**(`GroupRow[]`). `Post.groupBy('authorId').count()` → `{ authorId, count: bigint }[]` |
|
|
216
263
|
| `having` | `('count', op, val)` · `('sum'\|'avg'\|'min'\|'max', col, op, val)` | `GroupChain` | groupBy 뒤 **집계값** 필터 (그룹 키 필터는 `where`). `.having('count', '>', 2)` · `.having('sum', 'price', '>=', 1000)` |
|
|
217
|
-
| `distinct` | `()` · `(col \| col[])` | `Chain` · `SelectChain` | 인자 없으면 `SELECT DISTINCT` 전체 행(
|
|
264
|
+
| `distinct` | `()` · `(col \| col[])` | `Chain` · `SelectChain` | 인자 없으면 `SELECT DISTINCT` 전체 행(읽기 집계 이어짐), 컬럼을 주면 그 컬럼만 뽑는 `SelectChain`. `distinct().count()` 는 `count(distinct id)`. **벌크 쓰기로는 이어지지 않는다** — distinct 걸린 체인의 `updateAll`/`deleteAll` 등은 throw(위 벌크 계약) |
|
|
218
265
|
| `withCount` | `(...rels)` | `IncludedChain` | 관계별 개수를 **상관 서브쿼리**로 얹는다 — `withCount('comments')` → 각 Rec 에 `commentsCount: bigint`. 조인이 아니라 행이 안 늘어 `limit` 과 함께 써도 개수가 정확. **hasMany·hasOne·belongsToMany 만**(belongsTo 는 항상 0/1 이라 throw). `include` 와 같은 체인에 실린다(`include('author').withCount('comments')`) |
|
|
219
266
|
| `join` | `(table, 'table.col', 'self.col')` | `JoinChain` | INNER JOIN — **필터·정렬 수단**이고 반환은 **자기 테이블의 Rec**(조인 테이블 컬럼은 안 실림 → 뽑아야 하면 §5 `Post.query()`). **자기 테이블 컬럼은 한정 없이 그대로** 쓴다 — `t.timestamps()`·`t.id()` 로 양 테이블이 `createdAt`·`id` 를 공유해도 조인 시 자기 테이블로 자동 한정돼 `where('createdAt', ..)` 가 안전하다(ambiguous column 방지). **조인 테이블** 조건만 한정 이름(`where('users.name', '=', ...)`)으로 쓴다. 1:N 부풀림은 `distinct()` 로 접는다. `join`/`leftJoin`·`where`·`orderBy`·`distinct`·`select`·`pluck`·`count`·`exists`·`first`·`all` 이어짐 |
|
|
220
267
|
| `leftJoin` | `(table, 'table.col', 'self.col')` | `JoinChain` | LEFT OUTER JOIN — 짝 없는 자기 행도 남는다. "짝 없는 것만" = `.where('posts.id', 'is null')` |
|
|
@@ -304,7 +351,8 @@ methods: {
|
|
|
304
351
|
- `groupBy` 이후는 `GroupChain` — 결과가 그룹 행이라 `first`/`all` 대신
|
|
305
352
|
집계 함수가 종단이고, 레코드가 아니라 `include`·`select` 도 없다.
|
|
306
353
|
- `join`/`leftJoin` 이후는 `JoinChain` — 반환은 자기 Rec 이라 `include`·집계 그룹은
|
|
307
|
-
없지만 `where`/`whereAny`/`orWhere`/`orderBy`/`distinct`/`limit`/`offset`·스칼라
|
|
354
|
+
없지만 `where`/`whereAny`/`orWhere`/`orderBy`/`distinct`/`limit`/`offset`·스칼라 집계는
|
|
355
|
+
**`count`·`exists` 두 종만**(sum/avg/min/max 는 JoinChain 에 없다 — 필요하면 §5 `Post.query()`)·
|
|
308
356
|
`select`(자기 컬럼)·`pluck`·`first`/`all`/**`paginate`** 는 이어진다. 그래서 **텍스트
|
|
309
357
|
검색(whereAny)+관계 필터(join)+페이지네이션을 한 체인으로** 조립할 수 있다(읽기 조합).
|
|
310
358
|
- `select()` 이후엔 `include` 도 없다 (부분 행에 관계를 붙이지 않는다).
|
|
@@ -397,6 +445,73 @@ export const PlaceOrder = service(async (input: { userId: string; total: number
|
|
|
397
445
|
- **왜 `afterCommit`** — main 이 롤백되면 analytics 기록도 일어나지 않아야 한다.
|
|
398
446
|
`afterCommit` 은 커밋이 성공한 경우에만 콜백을 돈다(§9 · `agents/async.md`).
|
|
399
447
|
|
|
448
|
+
### 7.1 문서형 컬렉션 — `collection()` (v1.1 · `@gaonjs/adapter-mongo` · 결정 278~282)
|
|
449
|
+
|
|
450
|
+
SQL `model()`(Kysely) 옆에 문서형 동사 `collection()` 을 **Mongoose** 위에 둔다.
|
|
451
|
+
로그·이벤트·감사·분석처럼 **문서·유연 스키마·대량 append** 용도다. **`model()` 은
|
|
452
|
+
SQL 전용 · `collection()` 은 문서형** — 한 동사가 두 세계를 처리하지 않는다(The One Way).
|
|
453
|
+
|
|
454
|
+
문서형은 **opt-in 설치**다(SQL 전용 프로젝트는 mongoose 를 받지 않는다):
|
|
455
|
+
|
|
456
|
+
```bash
|
|
457
|
+
npm i @gaonjs/adapter-mongo mongoose
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
// domain/schema/auditLog.ts — mongoSchema() 는 진짜 Mongoose Schema 를 반환한다.
|
|
462
|
+
import { collection, mongoSchema } from 'gaonjs/data'
|
|
463
|
+
|
|
464
|
+
const auditLogSchema = mongoSchema({
|
|
465
|
+
actorId: { type: String, required: true, index: true },
|
|
466
|
+
action: { type: String, required: true },
|
|
467
|
+
ip: { type: String, hidden: true }, // 직렬화 경계 제외 (SQL .hidden() 과 동일 의미)
|
|
468
|
+
}, { timestamps: true })
|
|
469
|
+
|
|
470
|
+
// 순수 Mongoose 관례 — Gaon 이 메서드 DSL 을 재발명하지 않는다.
|
|
471
|
+
auditLogSchema.statics.recent = function (limit: number) {
|
|
472
|
+
return this.find().sort({ createdAt: -1 }).limit(limit).lean()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// db: 'logs' — 몽고 커넥션 바인딩 (SQL 의 { db: 'legacy' } 와 대칭)
|
|
476
|
+
export const AuditLog = collection('audit_logs', auditLogSchema, { db: 'logs' })
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
// gaon.config.ts — 문서형 커넥션은 adapter: 'mongodb' 로 SQL 과 나란히 선언한다.
|
|
481
|
+
export default defineConfig({
|
|
482
|
+
db: {
|
|
483
|
+
main: { adapter: 'postgres', url: env('DATABASE_URL') },
|
|
484
|
+
logs: { adapter: 'mongodb', url: env('MONGO_URL') },
|
|
485
|
+
},
|
|
486
|
+
})
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
**정본 규칙**:
|
|
490
|
+
- **`hidden` 은 SQL 과 같은 의미** — "서버는 읽고 직렬화만 제외". `select:false` 를
|
|
491
|
+
쓰지 않는다. render props 로 흘릴 땐 **`.lean()`** 을 쓴다(정본 경로) — `_id`(ObjectId)는
|
|
492
|
+
응답 경계에서 자동으로 string 이 된다(결정 282 · 결정 37 문서형 대응).
|
|
493
|
+
- **몽고 쓰기를 SQL `service()` tx 안에서 하지 말 것** — 몽고는 v1 에서 트랜잭션이
|
|
494
|
+
없어, tx 안 몽고 쓰기는 `MongoCrossConnectionWriteError` 로 **막힌다**(조용한 부분
|
|
495
|
+
커밋 방지 · 결정 281). "커밋 후 로그" 는 `afterCommit` 으로 잇는다(§7 크로스커넥션과 동일 규율):
|
|
496
|
+
```ts
|
|
497
|
+
export const SignUp = service(async (input) => {
|
|
498
|
+
const user = await Users.create(input) // main(SQL) 트랜잭션
|
|
499
|
+
afterCommit(() => AuditLog.create({ actorId: String(user.id), action: 'signup' })) // 커밋 뒤 몽고 쓰기
|
|
500
|
+
return user
|
|
501
|
+
})
|
|
502
|
+
```
|
|
503
|
+
- **SQL ↔ 문서형 관계 금지** — `belongsTo`/`hasMany` 는 SQL 전용이다. 몽고 컬렉션끼리의
|
|
504
|
+
참조는 Mongoose `ref`/`populate` 로 사용자가 직접 한다(Gaon doctor 가 강제하지 않음).
|
|
505
|
+
- **인덱스 = Mongoose 스키마 선언**(`index: true` / `schema.index()`) — dev 는 자동
|
|
506
|
+
`ensureIndexes`. 몽고는 마이그레이션이 없다(`gaon db diff/migrate` 는 mongo 를 건너뛴다).
|
|
507
|
+
|
|
508
|
+
**알려진 함정**:
|
|
509
|
+
- `aggregate` 결과는 임의 projection(그룹·계산)이라 `hidden` 마커를 심지 **않는다** —
|
|
510
|
+
SQL `Post.query()`(Kysely 원본) raw 탈출구가 hidden 을 우회하는 것과 동형. 문서를
|
|
511
|
+
안전하게 렌더에 흘리려면 `find()/findOne().lean()` 을 쓴다.
|
|
512
|
+
- 인스턴스 `document.save()` 는 크로스커넥션 가드 밖이다 — 정본 쓰기는 static
|
|
513
|
+
`create/update/delete`(가드 대상). tx 안에서 쓸 일이면 `afterCommit` 으로 미룬다.
|
|
514
|
+
|
|
400
515
|
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
|
|
401
516
|
|
|
402
517
|
`model()` 은 스키마(§1)를 Kysely 위의 실행 가능한 API 로 감싼다 —
|
|
@@ -800,8 +915,8 @@ const titles = await Post.pluck('title') // string[]
|
|
|
800
915
|
const slim = await Post.select(['id', 'title']).all() // Pick<Row, 'id' | 'title'>[]
|
|
801
916
|
|
|
802
917
|
// 삭제 — 단건은 레코드, 벌크는 deleteAll (M2C)
|
|
803
|
-
const
|
|
804
|
-
await
|
|
918
|
+
const doomed = await Post.find(id)
|
|
919
|
+
await doomed.delete()
|
|
805
920
|
const removed = await Post.where('published', '=', false).deleteAll() // number
|
|
806
921
|
const touched = await Post.where('authorId', '=', me.id).updateAll({ published: true })
|
|
807
922
|
|
|
@@ -820,10 +935,15 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
820
935
|
· 결정 119). 단건 삭제는 `rec.delete()`, 벌크는 `deleteAll()` (결정 31).
|
|
821
936
|
- **`find(id)` 는 없으면 throw** — undefined 를 원하면
|
|
822
937
|
`where('id', '=', id).first()`.
|
|
938
|
+
- **`find` 과 `where('id', …)` 의 id 타입은 비대칭** — `find(id)` 는
|
|
939
|
+
`bigint | string` 을 받는다(문자열은 그대로 드라이버에 넘긴다 · 별도 코드에서
|
|
940
|
+
`BigInt()` 강제 안 함). 반면 `where('id', '=', v)` 는 컬럼 타입 `Row['id']`(=bigint)를
|
|
941
|
+
요구해 **문자열을 넘기면 컴파일 에러**다. 라우트 파라미터(문자열)로 조회할 땐
|
|
942
|
+
`find(this.params.id)` 를 쓰거나, `where` 로는 `BigInt(this.params.id)` 로 감싼다.
|
|
823
943
|
- **관계 대상은 문자열 테이블명** — 모델 객체를 넘기면 순환 참조.
|
|
824
944
|
- **불규칙 복수**(`people`·`media` 등)는 단수화 관례가 못 잡는다 —
|
|
825
945
|
`foreignKey`/`otherKey` 를 명시한다.
|
|
826
|
-
- **`updateAll`/`deleteAll` 에 limit·offset·orderBy 가 걸려 있으면 throw** —
|
|
946
|
+
- **`updateAll`/`deleteAll` 에 limit·offset·orderBy·distinct 가 걸려 있으면 throw** —
|
|
827
947
|
행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`.
|
|
828
948
|
- **벌크 3종은 행이 아니라 `{ count }` 를 준다** (결정 90) — 반환을 `Rec[]` 처럼
|
|
829
949
|
다루지 말 것. 삽입된 행이 필요하면 소량은 `create()`, 대량은 유니크 키 재조회.
|
|
@@ -885,4 +1005,12 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
885
1005
|
| 결정 119 | `paginate(page, perPage)` — 체인 종단 `{rows,total,page,pageCount,perPage}` · 클램프·개수 number 내장 · UI 킷 Pagination 정합 · 손 조립(쿼리 2회·count 캐스팅·페이지 수학)은 반정본 · GroupChain 미탑재(행 목록 전용) |
|
|
886
1006
|
| 결정 148 | 캐시 헬퍼 `cache.remember`/`forget`·쿼리 `.withCache(ttl)` — 명시 TTL 만 · **자동 무효화 없음**(쓰기 자동 퍼지 기각 · 조용한 stale 방지) · Redis 기본·메모리 폴백(§8.2) |
|
|
887
1007
|
| 결정 153 | `Model.form` 컬럼 제약(`.max`·enum) 쓰기 전 서버측 검증 → 422(폼 에러) · DB 제약 위반 raw 500 방지(§8.1 · `this.params`) |
|
|
1008
|
+
| 결정 220 | 마이그레이션 diff 확장 — 기존 컬럼의 `.unique()`·`.index()`·`.default()`·`.check()` 추가/제거를 diff 로 잡음(§10) |
|
|
1009
|
+
| 결정 221 | 크로스 커넥션 트랜잭션 런타임 가드 — tx 안 다른 커넥션 **쓰기**(INSERT/UPDATE/DELETE) throw · **읽기는 예외** · 중첩 서비스/afterCommit/tx:false 는 자기 경계로 통과(§5 · 규칙 9) |
|
|
1010
|
+
| 결정 253 | hidden 마커를 **열거 가능한 심볼**로 부여 — `{ ...row }` spread·`Object.assign` 을 넘어 보존돼 우회 유출(S1) 봉합(§1) |
|
|
1011
|
+
| 결정 265 | 조인 시 자기 테이블 컬럼 자동 한정 — `where`/`whereAny`/`orderBy` 가 자기 테이블로 한정돼 ambiguous column 방지(§4 join) |
|
|
1012
|
+
| 결정 278 | 문서형 동사 `collection()`(Mongoose) — SQL `model()` 과 분리 · `mongoSchema()` 얇은 래퍼(판별 태그+hidden 전개) · `@gaonjs/adapter-mongo`(mongoose optional peer)(§7.1) |
|
|
1013
|
+
| 결정 279 | 문서형 커넥션 `adapter: 'mongodb'`(config `db` 맵) · `isTableDef` 가 collection 제외(tables.d.ts·doctor) · `gaon db` mongo 마이그 fail-loud(§7.1) |
|
|
1014
|
+
| 결정 281 | 몽고 쓰기를 SQL `service()` tx 안에서 하면 `MongoCrossConnectionWriteError` 로 막힘 — "커밋 후 로그" 는 `afterCommit`(§7.1 · 결정 221 동형) |
|
|
1015
|
+
| 결정 282 | ObjectId(`_id` 포함) → string 직렬화 정규화(응답 경계 · 결정 37 문서형 대응 · `.lean()` 권장)(§7.1) |
|
|
888
1016
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -55,6 +55,11 @@ const props = pageProps<'web:posts#index'>()
|
|
|
55
55
|
로 서버가 번역해 흘려보낸다(`agents/i18n.md` §5 · 결정 213).
|
|
56
56
|
`pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
|
|
57
57
|
`useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
|
|
58
|
+
- **구조분해 금지 — `pageProps` 와 같은 함정** (`packages/vue/src/shared.ts`). `useShared()`
|
|
59
|
+
는 매 접근마다 `usePage().props` 를 다시 읽는 **Proxy** 를 돌려준다. `const { csrf } =
|
|
60
|
+
useShared()` 처럼 구조분해하면 그 순간 값을 한 번 스냅샷해 **반응성이 끊긴다**(리다이렉트·
|
|
61
|
+
partial reload 후 flash·currentUser 갱신이 안 보인다). 항상 `const shared = useShared()`
|
|
62
|
+
로 받아 `shared.csrf`·`shared.flash` 로 접근한다.
|
|
58
63
|
- **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
|
|
59
64
|
로 import 하지 않는다.
|
|
60
65
|
- **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
|
|
@@ -71,9 +76,19 @@ const props = pageProps<'web:posts#index'>()
|
|
|
71
76
|
`router.visit(url)`. **외부 URL(`https://…`)·`target="_blank"` 만 `<a>`** 를
|
|
72
77
|
유지한다. 내부 경로 일반 앵커는 doctor **internal-anchor** 가 잡는다(결정 96).
|
|
73
78
|
- **페이지 제목 = `Head`(결정 271)** — 문서 `<title>` 은 `gaonjs/vue` 의 `Head` 로
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
설정한다. `createGaonApp({ title })` 조합자가 이 값을 받아 `글 목록 · 사이트명` 처럼
|
|
80
|
+
꾸민다. `document.title` 수동 조작·`@inertiajs` 직접 import 금지(결정 64) — 정본
|
|
81
|
+
표면은 `Head`(`gaonjs/vue` 재수출) 뿐이다. 페이지 `<template>` 최상단에 둔다:
|
|
82
|
+
```vue
|
|
83
|
+
<script setup lang="ts">
|
|
84
|
+
import { Head, pageProps } from 'gaonjs/vue'
|
|
85
|
+
const props = pageProps<'web:posts#index'>()
|
|
86
|
+
</script>
|
|
87
|
+
<template>
|
|
88
|
+
<Head title="글 목록" />
|
|
89
|
+
<!-- 이하 페이지 본문 -->
|
|
90
|
+
</template>
|
|
91
|
+
```
|
|
77
92
|
- **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
|
|
78
93
|
금지 (라우트를 모른다는 순수 규칙).
|
|
79
94
|
|
|
@@ -31,8 +31,9 @@ t('nav.home') // 중첩은 점 표기
|
|
|
31
31
|
|---|---|---|
|
|
32
32
|
| 번역 | `t(key, params?)` | 키는 카탈로그에서 타입 검사(아래 §4) · params 는 `{{name}}` 보간 |
|
|
33
33
|
| 현재 언어 | `currentLanguage(): string` | 요청 로케일 |
|
|
34
|
-
| 지원 언어 | `languages(): string[]` | 설정된 supportedLngs |
|
|
34
|
+
| 지원 언어 | `languages(): readonly string[]` | 설정된 supportedLngs(읽기 전용) |
|
|
35
35
|
| 고정 번역 | `runWithLanguage(lng, fn)` | fn 안의 t() 가 그 언어(메일·비요청 경로 · §mail) |
|
|
36
|
+
| 고정 번역기 | `translator(lng)` | 언어를 고정한 번역 함수 `(key, params?) => string` 를 돌려준다(테스트·비요청 경로) |
|
|
36
37
|
|
|
37
38
|
### 1.5 복수형 — `count` 로 자동 선택 (i18next 규약 · 결정 181)
|
|
38
39
|
|
|
@@ -142,6 +143,7 @@ nav 라벨·레이아웃 문구처럼 앱의 **모든** 페이지가 쓰는 chro
|
|
|
142
143
|
|
|
143
144
|
```ts
|
|
144
145
|
// apps/web/app.config.ts
|
|
146
|
+
import { defineAppConfig } from 'gaonjs/config'
|
|
145
147
|
import { t } from 'gaonjs/i18n'
|
|
146
148
|
export default defineAppConfig({
|
|
147
149
|
sharedProps: () => ({
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
메일은 `domain/mails/<이름>.ts` 에 `mail()` 로 정의한다(모델·잡과 같은 함수/객체
|
|
12
12
|
스타일 · 데코레이터 금지). 파일을 두면 등록이고 파일명이 곧 이름이다. build 함수는
|
|
13
|
-
데이터를 받아 메시지(`to`·`subject`·`html`/`text`·`from?`)를
|
|
13
|
+
데이터를 받아 메시지(`to`·`subject`·`html`/`text`·`from?`·`cc?`·`bcc?`·`replyTo?`)를
|
|
14
|
+
만든다. `to`·`cc`·`bcc` 는 `string | readonly string[]`(여러 수신자), `replyTo` 는 단일
|
|
15
|
+
`string` 이다. build 함수는 동기·비동기(`async`) 둘 다 된다(`MailMessage | Promise<MailMessage>`).
|
|
14
16
|
|
|
15
17
|
```ts
|
|
16
18
|
// domain/mails/welcome.ts
|
|
@@ -26,9 +28,9 @@ export const WelcomeMail = mail<{ name: string; email: string }>((u) => ({
|
|
|
26
28
|
|
|
27
29
|
| 표면 | 시그니처 | 비고 |
|
|
28
30
|
|---|---|---|
|
|
29
|
-
| 정의 | `mail<T>((data) => MailMessage)` | 파일명 = 이름 |
|
|
30
|
-
| 발송 | `def.deliver(data, { locale?, to? })
|
|
31
|
-
| 미리보기 | `def.render(data, { locale?, to? })
|
|
31
|
+
| 정의 | `mail<T>((data) => MailMessage \| Promise<MailMessage>)` | 파일명 = 이름 · build 는 async 가능 |
|
|
32
|
+
| 발송 | `def.deliver(data, { locale?, to? }): Promise<SentInfo>` | 설정된 SMTP 로 보냄(`await`) |
|
|
33
|
+
| 미리보기 | `def.render(data, { locale?, to? }): Promise<MailMessage>` | 발송 없이 메시지만(테스트·미리보기 · `await`) · `to` 는 `deliver` 와 대칭 |
|
|
32
34
|
|
|
33
35
|
### 2. 로케일 메일 — `deliver(data, { locale })` (결정 160)
|
|
34
36
|
|
|
@@ -60,7 +60,7 @@ export default channel({
|
|
|
60
60
|
|
|
61
61
|
| 훅 | 시점 | 반환 |
|
|
62
62
|
| --- | --- | --- |
|
|
63
|
-
| `authorize(ctx)` | 소켓 attach 전 | `boolean
|
|
63
|
+
| `authorize(ctx)` | 소켓 attach 전 | `boolean \| Promise<boolean>` — false 면 연결 거부(4401 close) · async 가능 |
|
|
64
64
|
| `presenceInfo(ctx)` | attach 전 | 접속자 목록에 실을 공개 메타 |
|
|
65
65
|
| `onJoin(ctx)` | 연결·프레즌스 등록 후 | — |
|
|
66
66
|
| `onMessage(ctx, data)` | 클라이언트 메시지 | — |
|
|
@@ -80,11 +80,15 @@ export default channel({
|
|
|
80
80
|
|
|
81
81
|
멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
|
|
82
82
|
|
|
83
|
-
**핸들러가 throw
|
|
84
|
-
예외(코드 결함·DB 순단)가 나도
|
|
85
|
-
마감되는 것과
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
**핸들러가 throw 해도 서버는 죽지 않는다(결정 257).** `onMessage`/`onLeave` 안에서
|
|
84
|
+
예외(코드 결함·DB 순단)가 나도 그 예외는 **연결 단위로 격리**된다 — HTTP 액션이 500
|
|
85
|
+
으로 마감되는 것과 대칭이다(다른 연결·서버는 그대로 산다). 단 **두 훅의 마감이 다르다**:
|
|
86
|
+
- `onMessage` throw → 그 연결에 **에러 프레임(`{ t:'error' }`)을 보내고 `1011` 로 종료**한다
|
|
87
|
+
(요청 실패지만 서버는 생존 = HTTP 500 대칭).
|
|
88
|
+
- `onLeave` throw → 연결이 **이미 닫히는 중**이라 통지할 곳이 없다. **로그만 남기고 정리(프레즌스
|
|
89
|
+
해제 등)를 계속**한다(추가 종료·에러 프레임 없음).
|
|
90
|
+
|
|
91
|
+
재시도가 필요한 로직은 핸들러 안에서 try/catch 로 감싸 직접 통제한다.
|
|
88
92
|
|
|
89
93
|
### 2.5 서버 개시 broadcast (결정 126)
|
|
90
94
|
|
|
@@ -332,7 +336,7 @@ export default channel({
|
|
|
332
336
|
| 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
|
|
333
337
|
| 결정 225 | 프레즌스 연결 축 refcount(§3) — 같은 멤버의 멀티탭·멀티서버 연결을 refcount 해 마지막 연결에서만 이탈 · cleanupServer 는 그 서버 연결만 회수(타서버 불간섭) |
|
|
334
338
|
| 결정 227 | 특정/다중 유저 타겟 발송(§2.6) — `sendToUsers(name, userIds, data)` · `broadcast` 와 대칭 · 대상 연결에만 전달(멀티서버·멀티탭) · 도달 유저 수 반환(오프라인=0) · 수정 1 연결 추적 위에 얹음 |
|
|
335
|
-
| 결정 257 | 채널 `onMessage`/`onLeave` throw 는
|
|
339
|
+
| 결정 257 | 채널 `onMessage`/`onLeave` throw 는 연결 단위로 격리(§2) — 사용자 핸들러 예외가 unhandledRejection 으로 serve 를 죽이지 않는다(HTTP 500 대칭) · `onMessage` = 에러 프레임 + `1011` 종료 · `onLeave` = 이미 닫히는 중이라 로그만·정리 계속 |
|
|
336
340
|
| 결정 259 | 허브 디스커버리 endpoint 는 소유 리더만 삭제(§5) — addr 일치 + revision CAS · standby 종료·리더 교대가 활성 endpoint 를 지우지 않음(재접속 서버 허브 발견 보존) |
|
|
337
341
|
| 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
|
|
338
342
|
| 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
|
|
@@ -59,7 +59,11 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
|
|
|
59
59
|
| CSRF · 무차별 요청(DoS) | ❌ | 세션 CSRF · rate limit |
|
|
60
60
|
| 작정한 공격자의 봉인 위조 | ❌(클라에 규약 있음) | 위 서버 검증 전부 |
|
|
61
61
|
|
|
62
|
-
##
|
|
62
|
+
## 정본 규칙
|
|
63
|
+
|
|
64
|
+
(§0 은 포지셔닝 프리앰블 — 켜기 전에 반드시 읽는다. 아래 §1~§5 가 실제 정본 규칙이다.)
|
|
65
|
+
|
|
66
|
+
### 1. 켜는 법 — The One Way (결정 121·124)
|
|
63
67
|
|
|
64
68
|
```bash
|
|
65
69
|
1) npm i @gaonjs/seal # 선택 플러그인 · 기본 스캐폴드 미포함
|
|
@@ -88,7 +92,7 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
|
88
92
|
- 미설치로 `seal: true` 를 켜면 **부팅 에러**(수리 안내). `masterSecret` 설정 표면은 없다 — 미끼 literal
|
|
89
93
|
이라 설정할 이유가 없다(비밀 착시·랜덤화 사고 방지).
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
### 2. 무엇이 봉인되나
|
|
92
96
|
|
|
93
97
|
- **요청/응답 JSON**: 클라 `installClientSeal()` 이 Inertia XHR 인터셉터(`XMLHttpRequest.prototype`) +
|
|
94
98
|
`api()`/`fetch` 봉인을 설치한다. 서버는 Fastify **4-stage 훅**(`plugin.ts` · onRequest 분류/fail-closed →
|
|
@@ -112,17 +116,18 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
|
112
116
|
- **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON 도 Inertia 도 아닌 HTML
|
|
113
117
|
직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
|
|
114
118
|
모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
|
|
115
|
-
- **fail-closed (403 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
116
|
-
**403 SealError** — 평문 통과 절대 없음.
|
|
119
|
+
- **fail-closed (403·413 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
120
|
+
**403 SealError** — 평문 통과 절대 없음. **과대 요청 본문(상한 초과 · `PAYLOAD_TOO_LARGE`)만 예외로 413**
|
|
121
|
+
(`readStream` OOM 방어 · `errors.ts`). WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
|
|
117
122
|
fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단).
|
|
118
123
|
|
|
119
|
-
|
|
124
|
+
### 3. CSP (결정 124)
|
|
120
125
|
|
|
121
126
|
seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입**한다(wasm 컴파일만 허용 ·
|
|
122
127
|
`'unsafe-eval'` 보다 좁음). **전역 완화 금지** — 비-seal 앱은 strict CSP(`script-src 'self'`) 그대로다.
|
|
123
128
|
요청 단위 판별(seal 스코프가 요청을 표시 · 보안 헤더 훅이 그 응답에만 보정).
|
|
124
129
|
|
|
125
|
-
|
|
130
|
+
### 4. 아키텍처 경계 (AI 가 넘지 말 것)
|
|
126
131
|
|
|
127
132
|
- **`@gaonjs/vue` 는 seal 무지 유지 · 비-seal 앱 번들에 wasm 0.** 두 번째 http/ws 클라이언트를 이식하지
|
|
128
133
|
않는다 — 봉인/개봉은 기존 전송 경로(Inertia·`api()`·`useChannel`) **경계 인터셉터**가 한다.
|
|
@@ -152,7 +157,7 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
152
157
|
- **허브(`gaon hub`)는 손대지 않는다** — 봉인/개봉은 각 웹서버의 소켓 경계에서만. 타 서버 접속자의
|
|
153
158
|
UA·ts 컨텍스트가 없어 허브가 프레임을 복호할 수 없는 것은 구조적 필연(설계상) · 허브·NATS 내부는 평문.
|
|
154
159
|
|
|
155
|
-
|
|
160
|
+
### 5. 게이트 — seal 검증은 **실 브라우저가 blocking** (결정 124)
|
|
156
161
|
|
|
157
162
|
- 정본 게이트는 **실 vite 프로덕션 빌드 + 실 chromium + 실 wasm** e2e 다:
|
|
158
163
|
`test/integration/seal-browser-e2e.integration.test.ts`(마운트·data-page 개봉·useForm POST·api()·WS `E:` 왕복·
|
|
@@ -168,6 +173,29 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
168
173
|
(Playwright `page.waitForResponse(...).text()`·`page.on('response')` · 외부 `curl`)로 **시그널 헤더 + 암호문**을
|
|
169
174
|
직접 봐야 드러난다. 정본 게이트 ⑨(결정 125)가 이 방식으로 네비게이션 wire 봉인을 정면 단언한다.
|
|
170
175
|
|
|
176
|
+
## 정본 예시
|
|
177
|
+
|
|
178
|
+
봉인은 **켜는 것**이 전부다 — 컨트롤러·`this.params`·`api()`·페이지 코드는 한 줄도 안 바뀐다(§2 wire 봉인은 훅/인터셉터가 담당).
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// apps/web/app.config.ts — 앱 wire 전체 봉인.
|
|
182
|
+
export default defineAppConfig({ seal: true })
|
|
183
|
+
```
|
|
184
|
+
```ts
|
|
185
|
+
// apps/web/main.ts — seal 클라이언트 정적 import 를 createGaonApp 에 주입(결정 124 · doctor 가 강제·fixer 자동 배선).
|
|
186
|
+
import { createGaonApp } from 'gaonjs/vue'
|
|
187
|
+
import * as sealClient from '@gaonjs/seal/client'
|
|
188
|
+
void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
189
|
+
```
|
|
190
|
+
```ts
|
|
191
|
+
// 컨트롤러는 그대로 — seal 을 전혀 모른다(요청/응답 JSON·최초 문서 data-page·WS 프레임이 자동 봉인).
|
|
192
|
+
export default controller({
|
|
193
|
+
async index() {
|
|
194
|
+
return this.render('Posts/Index', { posts: await Post.latest().all() })
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
171
199
|
## 알려진 함정
|
|
172
200
|
|
|
173
201
|
1. **main.ts 배선 누락** → 봉인 문서를 브라우저가 못 열어 화면 blank. → `gaon check`(`seal-security`)가 잡고 `gaon doctor --fix --yes` 가 배선. 런타임도 수리 안내 throw.
|
|
@@ -94,6 +94,11 @@
|
|
|
94
94
|
한다 — 폼(POST)을 추가하는 순간 CSRF 가 이미 켜져 있다. 앱에 비-GET
|
|
95
95
|
라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
|
|
96
96
|
`csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
|
|
97
|
+
- **CSRF 를 끄는 유일한 스위치는 `session: { csrf: false }` (결정 271 · 규칙 8).**
|
|
98
|
+
`app.config.ts` 의 `session` 에 `csrf: false` 를 명시할 때만 그 앱의 CSRF 검증이
|
|
99
|
+
꺼진다 — 생략·`undefined` 는 켬(보안 기본값). 커스텀 헤더 인증 등 세션 앱이면서
|
|
100
|
+
CSRF 를 꺼야 하는 특수 경로용 탈출구이고, 끄면 상태 변경 요청이 무방비가 되므로
|
|
101
|
+
이유를 남긴다. wire 가 이 값을 `SessionOptions.csrf` 로 그대로 전달한다.
|
|
97
102
|
- **CSRF/세션 실패는 코어가 Inertia-네이티브로 마감한다 (결정 165).** 세션 만료·
|
|
98
103
|
secret 로테이션·장시간 탭으로 CSRF 가 실패하면, Inertia 요청은 raw JSON 403 이 아니라
|
|
99
104
|
**409 + `X-Inertia-Location` 풀 리로드**(새 세션 쿠키+새 토큰) + `flash.error` 안내로
|
|
@@ -241,3 +246,6 @@ const rows = await Post.query()
|
|
|
241
246
|
| 결정 145 | 인가 프리미티브 `this.authorize(cond)` — 거짓 → 403(존재 은닉 시 404) · 인증(401)과 별개 축 · 저수준 탈출구 |
|
|
242
247
|
| 결정 149 | 인가 정책 객체 `policy()` + `this.can` — 재사용 규칙을 리소스별 액션→조건으로 묶음 · 값 객체(레지스트리 아님) · 가드는 `authorize(can(...))` 로 수렴 · authorize(cond) 무회귀(§2) |
|
|
243
248
|
| 결정 155 | `gaon g auth --app <비-web>` 시큐어 기본 — 공개 회원가입 미생성 + 역할 게이트(authorize) 예시 · web=공개가입 · `--public` opt-in(§2) |
|
|
249
|
+
| 결정 165 | 세션/CSRF 실패·415 를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 무 · 비-Inertia 는 JSON 유지(§2.3) |
|
|
250
|
+
| 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§2.3 · `this.auth`) |
|
|
251
|
+
| 결정 271 | CSRF 를 끄는 유일 스위치 = `session: { csrf: false }` — 생략은 켬(보안 기본값) · wire 가 `SessionOptions.csrf` 로 전달(§2.3) |
|
|
@@ -19,13 +19,16 @@
|
|
|
19
19
|
| 조회 | `Storage.get(key): Promise<Buffer \| null>` | 없으면 null |
|
|
20
20
|
| 삭제 | `Storage.delete(key)` | 멱등 |
|
|
21
21
|
| 존재 | `Storage.exists(key): Promise<boolean>` | |
|
|
22
|
-
| URL | `Storage.url(key, { expiresIn? }): Promise<string>` |
|
|
22
|
+
| URL | `Storage.url(key, { expiresIn? }): Promise<string>` | 드라이버별로 다름(아래) — 로컬=공개 경로 · s3=공개 URL 또는 presigned |
|
|
23
23
|
| 디스크 선택 | `Storage.disk('s3').put(...)` | 기본 디스크 외 다른 디스크로 |
|
|
24
24
|
|
|
25
|
-
- **URL 은 `Storage.url()` 한
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
- **URL 은 `Storage.url()` 한 곳**이지만 **드라이버로 갈린다**:
|
|
26
|
+
- **로컬 디스크**: 항상 `${baseUrl}/${key}` **공개 경로**를 만든다(서명 없음 · `expiresIn` 무시).
|
|
27
|
+
`baseUrl` 기본값은 `/storage`(웹이 그 경로를 정적 서빙·라우트로 노출) — presigned 개념이 없다.
|
|
28
|
+
- **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public)이 있으면 `${publicUrl}/${key}`,
|
|
29
|
+
없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
|
|
30
|
+
존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
|
|
31
|
+
표면은 `Storage.url()` 뿐이다.
|
|
29
32
|
- **키는 경로**다(`avatars/${user.id}.png`). 앞 슬래시는 정규화된다.
|
|
30
33
|
- **`contentType` 은 어댑터별로 다르게 반영된다**(결정 236): `s3` 는 객체
|
|
31
34
|
메타데이터로 저장해 다운로드·presign 시 그대로 나가고, **로컬**은 저장하지
|
|
@@ -55,7 +58,8 @@ storage: process.env.STORAGE_ENDPOINT
|
|
|
55
58
|
|
|
56
59
|
- dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
|
|
57
60
|
(결정 132 · zero-config). `cp .env.example .env && gaon dev` → 우회 0.
|
|
58
|
-
- 로컬 디스크: `{ driver: 'local', root: 'storage',
|
|
61
|
+
- 로컬 디스크: `{ driver: 'local', root: 'storage', baseUrl?: '/storage' }` — `url(key)`
|
|
62
|
+
= `baseUrl + '/' + key`(공개 경로 · `baseUrl` 생략 시 `/storage`).
|
|
59
63
|
- 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
|
|
60
64
|
만 env 로 바꾼다.
|
|
61
65
|
|
|
@@ -99,6 +103,19 @@ export default controller({
|
|
|
99
103
|
`<bucket>-test`, 로컬 root 는 `<root>-test` 로 격리된다(`<db>_test` 대칭 ·
|
|
100
104
|
`agents/testing.md`). 스토리지 잡 테스트는 스캐폴드 `test/setup.ts` 그대로 통과한다.
|
|
101
105
|
|
|
106
|
+
## 정본 예시
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// 저장 → 공개/서명 URL 얻기(드라이버 무관 · 같은 코드). url() 은 async.
|
|
110
|
+
const key = `avatars/${user.id}.png`
|
|
111
|
+
await Storage.put(key, buffer, { contentType: 'image/png' })
|
|
112
|
+
const src = await Storage.url(key) // 로컬=`/storage/avatars/<id>.png` · s3=공개 URL 또는 presigned
|
|
113
|
+
// 만료 있는 서명 URL(s3 · 로컬은 expiresIn 무시):
|
|
114
|
+
const tempLink = await Storage.url(key, { expiresIn: 600 })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
업로드(멀티파트) 수신·저장의 정본은 §3(`this.file('avatar')` → `Storage.put`).
|
|
118
|
+
|
|
102
119
|
## 알려진 함정
|
|
103
120
|
|
|
104
121
|
- **존재하지 않는 API 를 상상하지 말 것** — 파일 URL 은 `Storage.url()`,
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
- `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
|
|
26
26
|
테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
|
|
27
27
|
`GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 같은 접두가 **NATS KV 버킷**
|
|
28
|
-
(
|
|
29
|
-
|
|
28
|
+
(프레즌스·허브·역할별 리스)에도 적용돼(결정 203) 격리된다 — 리스 버킷은 결정 260 으로
|
|
29
|
+
역할마다 분리돼 `gaon_lease_<역할>`(예 `gaon_lease_hub_leader`) 이고, 테스트에선
|
|
30
|
+
`test_gaon_lease_hub_leader` 처럼 접두가 붙는다. 스트림뿐 아니라 KV 도 개발·운영 스택과 안 겹친다. 그래서
|
|
30
31
|
개발용 `gaon work` 가 떠 있어도 테스트 잡을 훔치지 않고 스케줄러 리더 경합도
|
|
31
32
|
안 생기며, 테스트가 남긴 잡을 개발 워커가 처리하지도 않는다 — **테스트 전에
|
|
32
33
|
워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이 격리가 없어 개발 스택과
|
|
@@ -42,6 +43,14 @@
|
|
|
42
43
|
- 통합 테스트는 `test/integration/<이름>.integration.test.ts` — 러너는
|
|
43
44
|
vitest (`gaon test --scope integration`).
|
|
44
45
|
- 단위 테스트는 소스 옆 `<이름>.test.ts` (`--scope unit`).
|
|
46
|
+
- **필터·인자 전달** — `gaon test <필터>`(예 `gaon test posts`)는 `posts` 를 vitest
|
|
47
|
+
파일명 필터로 그대로 넘긴다. `gaon test -- <vitest 인자>`(예 `gaon test -- --reporter=dot`)
|
|
48
|
+
로 vitest 플래그를 통과시킨다 — `--scope`/`--json` 만 gaon 이 소비하고 나머지는 전부
|
|
49
|
+
vitest 에 그대로 전달된다.
|
|
50
|
+
- **MCP `run_tests` 도 같은 하네스를 탄다(결정 270)** — 내장 MCP 서버의 `run_tests` 도구는
|
|
51
|
+
vitest 를 직접 spawn 하지 않고 `gaon test`(`runTestCommand`)에 위임한다. 그래서 AI 가
|
|
52
|
+
MCP 로 돌려도 테스트 DB 프로비저닝·`GAON_STREAM_PREFIX` 격리·사용자 `test` 스크립트
|
|
53
|
+
우선이 그대로 적용된다(필터·`--scope` 인자도 동일).
|
|
45
54
|
- `vi.mock('gaonjs/async')` · `vi.mock('nats')` · `vi.mock('@nats-io/…')`
|
|
46
55
|
같은 프레임웍·전송 목업은 금지다 (§9) — 실 접속으로 검증한다.
|
|
47
56
|
|
|
@@ -168,9 +168,11 @@ export default controller({
|
|
|
168
168
|
`this.file()` 업로드(멀티파트)의 CSRF 토큰은 **`x-csrf-token` 헤더**로 보낸다.
|
|
169
169
|
멀티파트는 `parts()` 스트리밍이라 CSRF 검사(preHandler) 시점에 **바디가 아직
|
|
170
170
|
파싱되지 않아** 폼 필드 `_csrf` 가 검사에 잡히지 않는다(구조적 한계 · 디스패처가
|
|
171
|
-
handler 안에서 파싱). 일반
|
|
171
|
+
handler 안에서 파싱). 일반 JSON 폼의 `_csrf` 바디 폴백은 멀티파트엔
|
|
172
172
|
통하지 않는다. 파일이 있으면 `useForm` 이 자동으로 multipart 로 보내므로, 업로드
|
|
173
|
-
제출은 **반드시 헤더**로 토큰을 실어야 한다.
|
|
173
|
+
제출은 **반드시 헤더**로 토큰을 실어야 한다. (참고: 지원 Content-Type 은
|
|
174
|
+
`application/json` · `multipart/form-data` 뿐이라 `x-www-form-urlencoded` 로 폼을
|
|
175
|
+
보내면 `_csrf` 폴백에 닿기 전에 415 로 거부된다 · `inertia.ts` · §아래 415.)
|
|
174
176
|
|
|
175
177
|
```vue
|
|
176
178
|
<script setup lang="ts">
|
|
@@ -576,10 +578,16 @@ export default controller({
|
|
|
576
578
|
| 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
|
|
577
579
|
| 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
|
|
578
580
|
| 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내) |
|
|
581
|
+
| 결정 64 | 폼 API 는 `gaonjs/vue` 의 `useForm`·`router` 뿐 — 로그아웃 등 DELETE 는 `router.delete()`(`@inertiajs/vue3` 직접 import 금지 · `Inertia.post()` 유령 API 아님) |
|
|
582
|
+
| 결정 122 | 관계·hidden 값이 render 경계 `serializeProps` 를 넘어 새지 않는다 — hidden 컬럼 제외 유지(§4.2) |
|
|
583
|
+
| 결정 165 | 세션/CSRF 실패·415(지원 안 되는 Content-Type)를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 으로 앱을 깨지 않는다(§4.1 · 지원 타입 `application/json`·`multipart/form-data`) |
|
|
584
|
+
| 결정 183 | 검증 사유 로케일화 — 안정 코드 + 예약 namespace `validation.<code>` 로 요청 로케일 번역(미제공 시 내장 fallback · §4.1) |
|
|
585
|
+
| 결정 253 | hidden 마커 = 열거 가능한 심볼 → `render(page, { ...user })` spread 우회로도 hidden 값이 안 샌다(§4.2) |
|
|
586
|
+
| 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§4.4 auth) |
|
|
579
587
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|
|
580
588
|
|
|
581
589
|
## `@gaonjs/seal` 켠 앱
|
|
582
590
|
|
|
583
591
|
`app.config seal: true` 면 그 앱의 wire(요청/응답 JSON + 최초 문서 data-page)가 봉인된다 — 컨트롤러·라우트·
|
|
584
592
|
`this.params`·`api()` 코드는 한 줄도 안 바뀐다. **정본은 `agents/seal.md`**(켜는 법·main.ts 배선·`except`·
|
|
585
|
-
자동 제외·fail-closed 403).
|
|
593
|
+
자동 제외·fail-closed = 평문 통과 금지 · 기본 403 · 과대 페이로드는 413).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,12 +28,12 @@
|
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
30
|
"@gaonjs/async": "0.15.3",
|
|
31
|
-
"@gaonjs/config": "0.
|
|
31
|
+
"@gaonjs/config": "0.19.0",
|
|
32
|
+
"@gaonjs/data": "0.21.0",
|
|
32
33
|
"@gaonjs/core": "0.2.4",
|
|
33
34
|
"@gaonjs/i18n": "0.2.4",
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/mail": "0.3.3"
|
|
36
|
-
"@gaonjs/web": "0.20.4"
|
|
35
|
+
"@gaonjs/web": "0.21.0",
|
|
36
|
+
"@gaonjs/mail": "0.3.3"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { DoctorCheck, RuleReport } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
* 단일 shared 컴포저블 소스를 검사해 순수성 위반 목록을 낸다
|
|
4
|
-
* (단위 테스트 진입점).
|
|
5
|
-
*/
|
|
6
|
-
export declare function inspectSharedComposable(file: string, source: string, cwd: string): DoctorCheck[];
|
|
7
|
-
/** shared/composables/ 를 훑어 순수성 위반을 모두 낸다. */
|
|
8
|
-
export declare function checkSharedComposablePurity(cwd: string): Promise<RuleReport>;
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// @gaonjs/cli · doctor · shared 컴포저블 순수성 검사 (M9-E 확장 · errata E-5 §2.2)
|
|
2
|
-
//
|
|
3
|
-
// shared/composables/ 는 shared 컴포넌트의 "props 로만" 규칙과 정확히
|
|
4
|
-
// 같은 구도를 따른다. 라우트를 몰라야 하고(api·pageProps 금지) · domain
|
|
5
|
-
// 은 타입으로만 참조해야 한다. 필요한 데이터·호출 함수는 인자로 받는다.
|
|
6
|
-
// 정본 근거: errata E-5 §2.2 (컴포저블·레이아웃 관례 · 결정 25).
|
|
7
|
-
//
|
|
8
|
-
// 검사 대상:
|
|
9
|
-
// 1) 프레임웍 모듈(gaonjs · gaonjs/vue · @gaonjs/vue · @gaonjs/web) 에서
|
|
10
|
-
// 'api' · 'pageProps' 를 value import 하면 error.
|
|
11
|
-
// - type-only 는 무해(구조 참조뿐 · 라우트 지식 필요 없음).
|
|
12
|
-
// 2) domain 을 value import 하면 error.
|
|
13
|
-
// - type-only 는 허용(모델 Row 타입 등 · 순수 타입 참조).
|
|
14
|
-
//
|
|
15
|
-
// 방법: shared/composables/*.ts 를 TS AST 로 파싱해 import 선언만 훑는다.
|
|
16
|
-
// 상대 import 는 실 파일까지 해석하지 않고 경로 접두사(domain/) 로 판정 —
|
|
17
|
-
// 이 검사는 순수성 게이트라 정확도보다 재현성이 우선(false positive 는
|
|
18
|
-
// 오히려 안전).
|
|
19
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
20
|
-
import { join, relative, resolve, dirname } from 'node:path';
|
|
21
|
-
import ts from 'typescript';
|
|
22
|
-
/** 라우트 지식을 담은 심볼 — shared 는 참조할 수 없다. */
|
|
23
|
-
const FORBIDDEN_FRAMEWORK_NAMES = new Set(['api', 'pageProps']);
|
|
24
|
-
/**
|
|
25
|
-
* 프레임웍 모듈 패턴. 문서 표기(gaon/vue)는 실 패키지 이름의 짧은
|
|
26
|
-
* 별칭이며, 실 배포본은 gaonjs 파사드 subpath 와 @gaonjs 스코프를 쓴다.
|
|
27
|
-
* 하나만 잡으면 우회가 쉬우므로 알려진 표기 3종을 모두 매치한다.
|
|
28
|
-
*/
|
|
29
|
-
const FRAMEWORK_MODULE_PATTERNS = [
|
|
30
|
-
/^gaonjs$/,
|
|
31
|
-
/^gaonjs\/vue$/,
|
|
32
|
-
/^@gaonjs\/vue$/,
|
|
33
|
-
/^@gaonjs\/web$/,
|
|
34
|
-
];
|
|
35
|
-
/** import 선언에서 이름과 type-only 플래그를 추출한다. */
|
|
36
|
-
function collectImportedNames(node) {
|
|
37
|
-
const out = [];
|
|
38
|
-
const clause = node.importClause;
|
|
39
|
-
if (!clause)
|
|
40
|
-
return out;
|
|
41
|
-
const clauseTypeOnly = clause.isTypeOnly;
|
|
42
|
-
if (clause.name)
|
|
43
|
-
out.push({ name: clause.name.text, typeOnly: clauseTypeOnly });
|
|
44
|
-
const bindings = clause.namedBindings;
|
|
45
|
-
if (bindings) {
|
|
46
|
-
if (ts.isNamespaceImport(bindings)) {
|
|
47
|
-
out.push({ name: bindings.name.text, typeOnly: clauseTypeOnly });
|
|
48
|
-
}
|
|
49
|
-
else if (ts.isNamedImports(bindings)) {
|
|
50
|
-
for (const el of bindings.elements) {
|
|
51
|
-
out.push({ name: el.name.text, typeOnly: clauseTypeOnly || el.isTypeOnly });
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return out;
|
|
56
|
-
}
|
|
57
|
-
function isRelativeSpecifier(s) {
|
|
58
|
-
return s.startsWith('./') || s.startsWith('../');
|
|
59
|
-
}
|
|
60
|
-
function matchesFrameworkModule(spec) {
|
|
61
|
-
return FRAMEWORK_MODULE_PATTERNS.some((re) => re.test(spec));
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* 단일 shared 컴포저블 소스를 검사해 순수성 위반 목록을 낸다
|
|
65
|
-
* (단위 테스트 진입점).
|
|
66
|
-
*/
|
|
67
|
-
export function inspectSharedComposable(file, source, cwd) {
|
|
68
|
-
const issues = [];
|
|
69
|
-
const rel = relative(cwd, file);
|
|
70
|
-
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
71
|
-
const baseDir = dirname(file);
|
|
72
|
-
const visit = (node) => {
|
|
73
|
-
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
74
|
-
const spec = node.moduleSpecifier.text;
|
|
75
|
-
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
76
|
-
const names = collectImportedNames(node);
|
|
77
|
-
// 1) 프레임웍의 api/pageProps 를 value import → error
|
|
78
|
-
if (matchesFrameworkModule(spec)) {
|
|
79
|
-
for (const n of names) {
|
|
80
|
-
if (n.typeOnly)
|
|
81
|
-
continue; // type-only 는 라우트 실행 지식이 아님
|
|
82
|
-
if (!FORBIDDEN_FRAMEWORK_NAMES.has(n.name))
|
|
83
|
-
continue;
|
|
84
|
-
issues.push({
|
|
85
|
-
rule: 'shared-composable-purity',
|
|
86
|
-
level: 'error',
|
|
87
|
-
file: rel,
|
|
88
|
-
line: line + 1,
|
|
89
|
-
message: `${rel} (line ${line + 1})\n` +
|
|
90
|
-
` '${spec}' 의 '${n.name}' import 발견 · shared 는 라우트를 몰라야 함\n` +
|
|
91
|
-
`→ 옵션: apps/<앱>/composables/ 로 옮기거나 · ${n.name} 호출을 인자로 받도록 바꾸라`,
|
|
92
|
-
detail: {
|
|
93
|
-
kind: 'framework-api',
|
|
94
|
-
module: spec,
|
|
95
|
-
name: n.name,
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// 2) domain value import → error (type-only 는 허용)
|
|
101
|
-
if (isRelativeSpecifier(spec)) {
|
|
102
|
-
const abs = resolve(baseDir, spec);
|
|
103
|
-
const parts = relative(cwd, abs).split(/[\\/]/).filter(Boolean);
|
|
104
|
-
if (parts[0] === 'domain') {
|
|
105
|
-
const valueNames = names.filter((n) => !n.typeOnly);
|
|
106
|
-
if (valueNames.length > 0) {
|
|
107
|
-
issues.push({
|
|
108
|
-
rule: 'shared-composable-purity',
|
|
109
|
-
level: 'error',
|
|
110
|
-
file: rel,
|
|
111
|
-
line: line + 1,
|
|
112
|
-
message: `${rel} (line ${line + 1})\n` +
|
|
113
|
-
` domain 값 import 발견 · shared 컴포저블은 domain 을 타입으로만 참조해야 함\n` +
|
|
114
|
-
`→ 'import type { ... } from ...' 형태로 바꾸거나 · 필요한 값은 인자로 받도록 바꾸라`,
|
|
115
|
-
detail: {
|
|
116
|
-
kind: 'domain-value',
|
|
117
|
-
module: spec,
|
|
118
|
-
names: valueNames.map((n) => n.name),
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
ts.forEachChild(node, visit);
|
|
126
|
-
};
|
|
127
|
-
visit(sf);
|
|
128
|
-
return issues;
|
|
129
|
-
}
|
|
130
|
-
/** shared/composables/ 를 훑어 순수성 위반을 모두 낸다. */
|
|
131
|
-
export async function checkSharedComposablePurity(cwd) {
|
|
132
|
-
const composablesDir = join(cwd, 'shared', 'composables');
|
|
133
|
-
const files = [];
|
|
134
|
-
await collectTsFiles(composablesDir, files);
|
|
135
|
-
const issues = [];
|
|
136
|
-
for (const file of files) {
|
|
137
|
-
const src = await readFile(file, 'utf8');
|
|
138
|
-
issues.push(...inspectSharedComposable(file, src, cwd));
|
|
139
|
-
}
|
|
140
|
-
return { rule: 'shared-composable-purity', issues };
|
|
141
|
-
}
|
|
142
|
-
async function collectTsFiles(root, out) {
|
|
143
|
-
let entries;
|
|
144
|
-
try {
|
|
145
|
-
entries = (await readdir(root, { withFileTypes: true }));
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
for (const e of entries) {
|
|
151
|
-
const name = e.name;
|
|
152
|
-
if (name === 'node_modules' || name === 'dist' || name === '.gaon')
|
|
153
|
-
continue;
|
|
154
|
-
const full = join(root, name);
|
|
155
|
-
if (e.isDirectory())
|
|
156
|
-
await collectTsFiles(full, out);
|
|
157
|
-
else if (e.isFile() &&
|
|
158
|
-
name.endsWith('.ts') &&
|
|
159
|
-
!name.endsWith('.d.ts') &&
|
|
160
|
-
!name.endsWith('.test.ts')) {
|
|
161
|
-
out.push(full);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|