@gaonjs/cli 0.42.3 → 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/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 +1 -1
- package/dist/templates/project/agents/data.md.tpl +127 -9
- 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/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}개 테이블`);
|
|
@@ -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
|
|
|
@@ -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
|
체이닝 표면은 아래 표가 **전부**다. 표에 없는 메서드
|
|
@@ -398,6 +445,73 @@ export const PlaceOrder = service(async (input: { userId: string; total: number
|
|
|
398
445
|
- **왜 `afterCommit`** — main 이 롤백되면 analytics 기록도 일어나지 않아야 한다.
|
|
399
446
|
`afterCommit` 은 커밋이 성공한 경우에만 콜백을 돈다(§9 · `agents/async.md`).
|
|
400
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
|
+
|
|
401
515
|
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
|
|
402
516
|
|
|
403
517
|
`model()` 은 스키마(§1)를 Kysely 위의 실행 가능한 API 로 감싼다 —
|
|
@@ -895,4 +1009,8 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
895
1009
|
| 결정 221 | 크로스 커넥션 트랜잭션 런타임 가드 — tx 안 다른 커넥션 **쓰기**(INSERT/UPDATE/DELETE) throw · **읽기는 예외** · 중첩 서비스/afterCommit/tx:false 는 자기 경계로 통과(§5 · 규칙 9) |
|
|
896
1010
|
| 결정 253 | hidden 마커를 **열거 가능한 심볼**로 부여 — `{ ...row }` spread·`Object.assign` 을 넘어 보존돼 우회 유출(S1) 봉합(§1) |
|
|
897
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) |
|
|
898
1016
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
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
|
-
"@gaonjs/data": "0.17.3",
|
|
34
34
|
"@gaonjs/i18n": "0.2.4",
|
|
35
|
-
"@gaonjs/
|
|
36
|
-
"@gaonjs/
|
|
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
|
-
}
|