@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 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
- const userTables = Object.keys(snapshot).sort();
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
- // dialect.dropTable CASCADE 를 붙인다(postgres). mysql 은 SET FOREIGN_KEY_CHECKS
98
- // 우회가 필요하지만, 스키마 대상 테이블만 다루므로 대체로 안전.
99
- for (const t of userTables) {
100
- await sql.raw(target.dialect.dropTable(t)).execute(target.db);
101
- droppedTables.push(t);
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 {
@@ -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.map((u) => `[${u.join(', ')}]`).join(', ')}`);
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 posts = table('posts', {
157
- slug: t.string().max(100),
158
- ownerId: t.belongsTo('users'),
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', // 커넥션 키 (§7)
162
- unique: [['ownerId', 'slug']], // 복합 unique
163
- index: [['ownerId', 'createdAt']], // 복합 index
164
- check: [['positive_age', 'age > 0']], // [name, expr]
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.42.3",
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.18.1",
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/mail": "0.3.3",
36
- "@gaonjs/web": "0.20.5"
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
- }