@gaonjs/cli 0.52.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/commands/check.d.ts +2 -0
  2. package/dist/commands/check.js +43 -1
  3. package/dist/commands/db.js +9 -0
  4. package/dist/commands/gen.d.ts +2 -0
  5. package/dist/commands/gen.js +3 -1
  6. package/dist/commands/new.js +13 -0
  7. package/dist/commands/test.js +28 -4
  8. package/dist/db/journal.d.ts +4 -1
  9. package/dist/db/journal.js +37 -4
  10. package/dist/db/migrate.js +11 -11
  11. package/dist/db/replay.js +1 -1
  12. package/dist/db/resolve.d.ts +11 -1
  13. package/dist/db/resolve.js +24 -2
  14. package/dist/db/status.js +9 -6
  15. package/dist/db.js +26 -5
  16. package/dist/dev.js +2 -2
  17. package/dist/doctor/auth-wiring.js +5 -2
  18. package/dist/doctor/channel-collision.d.ts +9 -0
  19. package/dist/doctor/channel-collision.js +119 -0
  20. package/dist/doctor/fixers/index.d.ts +1 -1
  21. package/dist/doctor/fixers/index.js +6 -1
  22. package/dist/doctor/locale-parity.js +2 -2
  23. package/dist/doctor/types.d.ts +1 -1
  24. package/dist/doctor.d.ts +10 -2
  25. package/dist/doctor.js +45 -3
  26. package/dist/generate.d.ts +5 -0
  27. package/dist/generate.js +11 -3
  28. package/dist/i18n-config.d.ts +20 -0
  29. package/dist/i18n-config.js +87 -12
  30. package/dist/index.js +111 -26
  31. package/dist/mcp/tools.js +4 -0
  32. package/dist/messages-gen.js +11 -3
  33. package/dist/scaffold/controller.js +3 -1
  34. package/dist/scaffold/page.js +6 -4
  35. package/dist/templates/project/AGENTS.md.tpl +9 -5
  36. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  37. package/dist/templates/project/Dockerfile.tpl +11 -1
  38. package/dist/templates/project/agents/async.md.tpl +56 -14
  39. package/dist/templates/project/agents/data.md.tpl +146 -35
  40. package/dist/templates/project/agents/frontend.md.tpl +24 -10
  41. package/dist/templates/project/agents/i18n.md.tpl +32 -4
  42. package/dist/templates/project/agents/mail.md.tpl +6 -0
  43. package/dist/templates/project/agents/realtime.md.tpl +115 -16
  44. package/dist/templates/project/agents/seal.md.tpl +13 -4
  45. package/dist/templates/project/agents/security.md.tpl +29 -4
  46. package/dist/templates/project/agents/storage.md.tpl +57 -13
  47. package/dist/templates/project/agents/testing.md.tpl +58 -0
  48. package/dist/templates/project/agents/web.md.tpl +171 -15
  49. package/dist/work.d.ts +3 -0
  50. package/dist/work.js +4 -0
  51. package/package.json +7 -7
@@ -40,6 +40,8 @@ export interface CheckRegen {
40
40
  readonly tables: boolean;
41
41
  /** routes.d.ts 를 재생성한 앱 이름. */
42
42
  readonly apps: readonly string[];
43
+ /** messages.d.ts(i18n 카탈로그 키 유니온)를 재생성했는가 — 결정 414(축 보고 누락 봉합). */
44
+ readonly messages?: boolean;
43
45
  /** 실패 시 에러 + 수리 안내. done/skipped 는 undefined. */
44
46
  readonly output?: string;
45
47
  }
@@ -32,6 +32,8 @@ import { generateTablesDts } from '@gaonjs/data';
32
32
  import { generateRoutesDts } from '@gaonjs/web';
33
33
  import { computeDoctorResult } from '../doctor.js';
34
34
  import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
35
+ import { analyzeProjectI18n, resolveLocalesDir } from '../i18n-config.js';
36
+ import { loadLocales } from '@gaonjs/i18n';
35
37
  import { registerTsResolve } from '../tsResolve.js';
36
38
  import { listFrontendApps, verifyAppDist } from '../dev/build.js';
37
39
  import { generateMessagesDts } from '../messages-gen.js';
@@ -239,9 +241,41 @@ async function runDoctorStep(cwd) {
239
241
  * 실패를 내지 않게 한다. --only 여부와 무관하게 항상 먼저 돈다 —
240
242
  * typecheck·vue-tsc·build 가 모두 .gaon 을 소비하기 때문.
241
243
  */
244
+ /**
245
+ * 결정 413: 결정 353(부팅 fail-loud)의 정적 미러 — i18n 을 켰는데 카탈로그가 비었거나
246
+ * fallbackLng/supportedLngs 가 지목한 파일이 없으면 부팅이 죽는다. 같은 사유·같은 수리
247
+ * 안내를 check 단계에서 먼저 낸다. 정적으로 못 읽은 키는 건너뛴다(결정 412 경고가 별도로 알림).
248
+ */
249
+ function verifyI18nCatalogs(cwd) {
250
+ const cfg = analyzeProjectI18n(cwd);
251
+ if (!cfg.declared)
252
+ return undefined;
253
+ const dir = cfg.dir ?? 'locales';
254
+ const abs = resolveLocalesDir(cwd, cfg.dir);
255
+ const resources = existsSync(abs) ? loadLocales(abs) : {};
256
+ if (Object.keys(resources).length === 0) {
257
+ return (`i18n 이 설정됐지만 로케일 카탈로그가 비어 있습니다: ${abs}\n` +
258
+ ` → ${dir}/${cfg.fallbackLng ?? '<fallbackLng>'}.json 을 만들거나(최소 fallbackLng 카탈로그),\n` +
259
+ ` → gaon.config.ts 의 i18n.dir 이 실제 카탈로그 폴더를 가리키는지 확인하세요.\n` +
260
+ ` (이 상태로는 gaon serve 가 부팅에서 실패합니다 — 결정 353)`);
261
+ }
262
+ const wanted = [...(cfg.fallbackLng ? [cfg.fallbackLng] : []), ...(cfg.supportedLngs ?? [])];
263
+ const missing = wanted.filter((lng, i, arr) => arr.indexOf(lng) === i && !resources[lng]);
264
+ if (missing.length > 0) {
265
+ return (`i18n 설정이 지목한 로케일의 카탈로그 파일이 없습니다: ${missing.join(', ')}\n` +
266
+ ` → ${dir}/ 에 ${missing.map((l) => `${l}.json`).join(' · ')} 을 추가하거나,\n` +
267
+ ` → gaon.config.ts 의 fallbackLng/supportedLngs 에서 해당 로케일을 제거하세요.\n` +
268
+ ` (이 상태로는 gaon serve 가 부팅에서 실패합니다 — 결정 353)`);
269
+ }
270
+ return undefined;
271
+ }
242
272
  async function regenerateGaon(cwd) {
243
273
  const layout = resolveDevLayout(cwd);
244
274
  if (!layout.schemaDir && layout.apps.length === 0 && !layout.localesDir) {
275
+ // 카탈로그가 통째로 없어도 config 가 i18n 을 켰으면 부팅이 죽는다 — 그 경우만 잡는다.
276
+ const problem = verifyI18nCatalogs(cwd);
277
+ if (problem)
278
+ return { status: 'failed', tables: false, apps: [], output: problem };
245
279
  return { status: 'skipped', tables: false, apps: [] };
246
280
  }
247
281
  // 생성기는 사용자 스키마·컨트롤러 .ts 를 동적 import 한다. TS-for-ESM
@@ -255,7 +289,15 @@ async function regenerateGaon(cwd) {
255
289
  regenerateMessages: generateMessagesDts,
256
290
  regenerateEnv: generateEnvDts,
257
291
  });
258
- return { status: 'done', tables: result.tables, apps: result.apps };
292
+ // 결정 413: 부팅(결정 353)이 throw 하는 조건을 **정적으로 미리** 본다 — check 는
293
+ // AI 자가검증의 정본 경로인데, 통과해 놓고 `gaon serve` 가 죽으면 자가검증이
294
+ // 부팅 가능성을 담보하지 못한다(i18n 한정 구멍). 재료(dir·fallbackLng·supportedLngs)는
295
+ // 이미 정적으로 있다.
296
+ const i18nProblem = verifyI18nCatalogs(cwd);
297
+ if (i18nProblem) {
298
+ return { status: 'failed', tables: result.tables, apps: result.apps, messages: result.messages, output: i18nProblem };
299
+ }
300
+ return { status: 'done', tables: result.tables, apps: result.apps, messages: result.messages };
259
301
  }
260
302
  catch (err) {
261
303
  const msg = err instanceof Error ? err.message : String(err);
@@ -79,6 +79,15 @@ export async function runDbCommand(subcommand, opts = {}) {
79
79
  emit(r.text, r.json);
80
80
  return r.exitCode;
81
81
  }
82
+ // 결정 367: seed 는 커넥션별 반복이 아니라 **1회 실행**이다 — domain/seed.ts 는
83
+ // 프로젝트 단일 시드라 키당 재실행하면 중복 삽입이고, 패스마다 단일 커넥션만
84
+ // 등록해 타 커넥션 모델이 '미등록' throw 로 순회가 항상 exit 1 이었다.
85
+ // runDbSeedCommand 가 선언된 전 SQL 커넥션을 등록하고 한 번 실행한다.
86
+ if (subcommand === 'seed') {
87
+ const r = await runDbSeedCommand({ root: cwd, json, configPath });
88
+ emit(r.text, r.json);
89
+ return r.exitCode;
90
+ }
82
91
  return runAcrossConnections(subcommand, keys, runOne, emit, json, mongoKeys);
83
92
  }
84
93
  // 단일 커넥션 경로(--db <키> · reset · env-only 폴백) — 종전 동작 그대로.
@@ -13,6 +13,8 @@ export interface GenResult {
13
13
  readonly apps: readonly string[];
14
14
  /** env.d.ts 를 재생성했는가(프론트 앱 존재 · 결정 198). */
15
15
  readonly env?: boolean;
16
+ /** messages.d.ts 를 재생성했는가(locales/ 존재 · 결정 158). 결정 414: 재생성만 하고 보고에서 빠져 있었다. */
17
+ readonly messages?: boolean;
16
18
  /** 실패 시 에러 + 수리 안내. */
17
19
  readonly error?: string;
18
20
  }
@@ -48,7 +48,7 @@ export async function runGenCommand(opts = {}) {
48
48
  let result;
49
49
  try {
50
50
  const r = await regenerateProjectGaon(cwd);
51
- result = { ok: true, skipped: r.skipped, tables: r.tables, apps: r.apps, env: r.env };
51
+ result = { ok: true, skipped: r.skipped, tables: r.tables, apps: r.apps, env: r.env, messages: r.messages };
52
52
  }
53
53
  catch (err) {
54
54
  const msg = err instanceof Error ? err.message : String(err);
@@ -77,6 +77,8 @@ export async function runGenCommand(opts = {}) {
77
77
  for (const app of result.apps) {
78
78
  parts.push(`apps/${app}/.gaon/routes.d.ts`, `apps/${app}/.gaon/routes.manifest.ts`);
79
79
  }
80
+ if (result.messages)
81
+ parts.push('.gaon/messages.d.ts');
80
82
  if (result.env)
81
83
  parts.push('.gaon/env.d.ts');
82
84
  process.stdout.write(` ✓ gaon gen — ${parts.join(', ')}\n`);
@@ -44,6 +44,19 @@ export function validateProjectName(name) {
44
44
  ` 대문자·언더스코어(_)는 인프라 기동·버킷 생성에서 실패합니다.\n` +
45
45
  ` → 예: gaon new my-app · gaon new demo`);
46
46
  }
47
+ // 결정 409: 결정 315 가 남긴 같은 클래스의 잔여 두 갈래 — 버킷명은 **영숫자로 끝나야**
48
+ // 하고 **3~63자**여야 한다. 종전 정규식은 'my-app-'·'ab' 를 통과시켜 생성은 성공하고
49
+ // `gaon dev` 의 버킷 생성(mc mb)에서 뒤늦게 깨졌다.
50
+ if (name.endsWith('-')) {
51
+ throw new Error(`프로젝트 이름은 하이픈(-)으로 끝날 수 없습니다: ${name}\n` +
52
+ ` → 스토리지 버킷명은 영문/숫자로 끝나야 해 'mc mb' 가 실패합니다.\n` +
53
+ ` → 예: gaon new ${name.replace(/-+$/, '')}`);
54
+ }
55
+ if (name.length < 3 || name.length > 63) {
56
+ throw new Error(`프로젝트 이름은 3~63자여야 합니다(현재 ${name.length}자): ${name}\n` +
57
+ ` → 이름이 스토리지 버킷명으로 그대로 쓰이고, 버킷명 길이 제한이 3~63자입니다.\n` +
58
+ ` → 예: gaon new my-app`);
59
+ }
47
60
  }
48
61
  /**
49
62
  * 파사드(gaonjs) 패키지의 실 버전을 찾는다 — package.json 을 위로 훑는다.
@@ -94,8 +94,19 @@ async function provisionTestDatabases(cwd, json, onOutput) {
94
94
  if (dbs.length === 0)
95
95
  return true;
96
96
  const prepared = [];
97
+ // 결정 279·292: 문서형(mongodb)은 SQL 프로비저닝(CREATE DATABASE + 마이그레이션)이 없다 —
98
+ // 순회에서 건너뛰고 보고에만 남긴다. 이 skip 이 없으면 SQL 전용 deriveTestDatabaseConfig/
99
+ // ensureTestDatabaseExists 가 몽고 커넥션에 pg/mysql 드라이버로 붙으려다 throw 해,
100
+ // 몽고 커넥션이 하나라도 있는 프로젝트는 `gaon test` 가 테스트 실행 전에 즉사했다
101
+ // (testkit·`gaon db` 는 이미 건너뛰는데 CLI test 만 빠진 비대칭). 테스트 레인의 몽고
102
+ // 커넥션 자체는 connectTestDatabase(@gaonjs/config testkit)가 등록한다.
103
+ const skippedMongo = [];
97
104
  try {
98
105
  for (const [key, cfg] of dbs) {
106
+ if (cfg.adapter === 'mongodb') {
107
+ skippedMongo.push(key);
108
+ continue;
109
+ }
99
110
  const testCfg = deriveTestDatabaseConfig(cfg);
100
111
  await ensureTestDatabaseExists(testCfg);
101
112
  const res = await runDbMigrate({
@@ -130,10 +141,23 @@ async function provisionTestDatabases(cwd, json, onOutput) {
130
141
  finally {
131
142
  await destroyAllConnections();
132
143
  }
133
- if (json)
134
- writeOut(JSON.stringify({ kind: 'provisioned', dbs: prepared }) + '\n');
135
- else
136
- writeOut(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
144
+ if (json) {
145
+ writeOut(JSON.stringify({
146
+ kind: 'provisioned',
147
+ dbs: prepared,
148
+ skipped: skippedMongo.map((db) => ({ db, adapter: 'mongodb', reason: 'documental-no-migration' })),
149
+ }) + '\n');
150
+ }
151
+ else {
152
+ // SQL 커넥션이 하나도 없으면(문서형 전용 프로젝트) "준비 완료 ()" 대신 건너뜀만 알린다.
153
+ if (prepared.length > 0) {
154
+ writeOut(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
155
+ }
156
+ if (skippedMongo.length > 0) {
157
+ writeOut(` gaon test · 문서형(mongodb) 커넥션 건너뜀: ${skippedMongo.join(', ')} — 문서형은 마이그레이션이 없습니다(§7.4).\n` +
158
+ ` → 테스트 레인의 몽고 커넥션은 test/setup.ts 의 connectTestDatabase() 가 <db>_test 로 등록합니다(결정 279).\n`);
159
+ }
160
+ }
137
161
  return true;
138
162
  }
139
163
  /**
@@ -23,8 +23,11 @@ export declare function journalExists(db: Kysely<any>, adapter?: AdapterName): P
23
23
  * _gaon_migrations 를 만든다(존재하면 no-op). id/kind/applied_at/db_key/
24
24
  * statements/summary/down_sql — text·varchar·timestamp·integer 는 postgres·
25
25
  * mysql 공통 지원 타입. kind 기본값 'schema' 는 구버전 스키마 호환용.
26
+ * 결정 380: mysql 의 down_sql 은 **mediumtext**(16MB) — TEXT(64KB)는 큰 배치의
27
+ * down SQL(JSON 배열)이 조용히 잘려 저장 파손 위험이었다. 기존(legacy) 테이블은
28
+ * data_type 확인 후 한 번만 MODIFY 로 승격한다(매 실행 재빌드 방지). pg text 는 무제한.
26
29
  */
27
- export declare function ensureJournal(db: Kysely<any>): Promise<void>;
30
+ export declare function ensureJournal(db: Kysely<any>, adapter?: AdapterName): Promise<void>;
28
31
  export interface RecordArgs {
29
32
  readonly id: string;
30
33
  readonly kind: MigrationKind;
@@ -41,26 +41,59 @@ export async function journalExists(db, adapter = 'postgres') {
41
41
  * _gaon_migrations 를 만든다(존재하면 no-op). id/kind/applied_at/db_key/
42
42
  * statements/summary/down_sql — text·varchar·timestamp·integer 는 postgres·
43
43
  * mysql 공통 지원 타입. kind 기본값 'schema' 는 구버전 스키마 호환용.
44
+ * 결정 380: mysql 의 down_sql 은 **mediumtext**(16MB) — TEXT(64KB)는 큰 배치의
45
+ * down SQL(JSON 배열)이 조용히 잘려 저장 파손 위험이었다. 기존(legacy) 테이블은
46
+ * data_type 확인 후 한 번만 MODIFY 로 승격한다(매 실행 재빌드 방지). pg text 는 무제한.
44
47
  */
45
- export async function ensureJournal(db) {
48
+ export async function ensureJournal(db, adapter = 'postgres') {
49
+ const downType = adapter === 'mysql' ? 'mediumtext' : 'text';
46
50
  await sql
47
51
  .raw(`create table if not exists ${MIGRATIONS_TABLE} (` +
48
52
  ` id varchar(255) not null primary key,` +
49
53
  ` kind varchar(16) not null default 'schema',` +
50
- ` applied_at timestamp not null,` +
54
+ ` applied_at timestamp(6) not null,` +
51
55
  ` db_key varchar(64) not null,` +
52
56
  ` statements integer not null,` +
53
57
  ` summary text not null,` +
54
- ` down_sql text` +
58
+ ` down_sql ${downType}` +
55
59
  `)`)
56
60
  .execute(db);
61
+ if (adapter === 'mysql') {
62
+ // 결정 380: legacy 테이블 승격 — down_sql text→mediumtext(64KB 절단 방지) ·
63
+ // applied_at 초 해상 timestamp→timestamp(6)(ms 단조 증가가 저장에서 뭉개지지 않게).
64
+ // 둘 다 상태 확인 후 한 번만 MODIFY(매 실행 테이블 재빌드 방지).
65
+ const cur = await sql `
66
+ select column_name as c, data_type as t, datetime_precision as p
67
+ from information_schema.columns
68
+ where table_schema = database() and table_name = ${MIGRATIONS_TABLE}
69
+ and column_name in ('down_sql', 'applied_at')
70
+ `.execute(db);
71
+ for (const row of cur.rows) {
72
+ if (row.c === 'down_sql' && row.t.toLowerCase() === 'text') {
73
+ await sql.raw(`alter table ${MIGRATIONS_TABLE} modify down_sql mediumtext`).execute(db);
74
+ }
75
+ if (row.c === 'applied_at' && Number(row.p ?? 0) === 0) {
76
+ await sql.raw(`alter table ${MIGRATIONS_TABLE} modify applied_at timestamp(6) not null`).execute(db);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ // 결정 380: 프로세스 내 단조 증가 applied_at — 같은 migrate 실행의 파일 리플레이·schema
82
+ // 배치가 같은 ms 에 기록되면 롤백 대상 선정(`order by applied_at desc, id desc`)이 서로
83
+ // 다른 id 체계(파일명 vs `<epochMs>-<n>`)의 **사전순**으로 갈려 최신이 아닌 항목을 되돌릴
84
+ // 수 있었다. 동률이면 1ms 씩 밀어 순서를 시각에 각인한다(동시 다중 migrate 는 원래 미지원).
85
+ let lastAppliedAtMs = 0;
86
+ function nextAppliedAt() {
87
+ const now = Date.now();
88
+ lastAppliedAtMs = now > lastAppliedAtMs ? now : lastAppliedAtMs + 1;
89
+ return new Date(lastAppliedAtMs);
57
90
  }
58
91
  /** 이력 한 행을 남긴다. 호출자가 트랜잭션(trx)을 넘기면 그 안에서 실행된다. */
59
92
  export async function recordEntry(db, e) {
60
93
  await sql `
61
94
  insert into ${sql.ref(MIGRATIONS_TABLE)}
62
95
  (id, kind, applied_at, db_key, statements, summary, down_sql)
63
- values (${e.id}, ${e.kind}, ${new Date()}, ${e.dbKey}, ${e.statements}, ${e.summary}, ${e.downSql})
96
+ values (${e.id}, ${e.kind}, ${nextAppliedAt()}, ${e.dbKey}, ${e.statements}, ${e.summary}, ${e.downSql})
64
97
  `.execute(db);
65
98
  }
66
99
  /** 가장 최근 적용된 이력(롤백 대상). 원장 미존재 시 undefined. */
@@ -12,8 +12,7 @@
12
12
  //
13
13
  // 트랜잭션: postgres 는 트랜잭셔널 DDL 이라 각 단계가 원자적. mysql/mariadb 는
14
14
  // DDL 이 autocommit 이라 순차 실행하고 이력만 남긴다(§4.5 방언 차이).
15
- import { sql } from 'kysely';
16
- import { computeMigration, renderUp, renderDown, summarizeOp, DEFERRED_DROP_KINDS } from '@gaonjs/data';
15
+ import { applyStatements, computeMigration, renderUp, renderDown, summarizeOp, partitionDeferredOps } from '@gaonjs/data';
17
16
  import { resolveDbTarget } from './resolve.js';
18
17
  import { ensureJournal, journalExists, recordEntry } from './journal.js';
19
18
  import { listMigrationFiles, replayPending, rollbackLast } from './replay.js';
@@ -62,12 +61,15 @@ export async function runDbMigrate(opts) {
62
61
  // 자동 적용하지 않는다** — replay 가 만든/외부 객체·수동 추가한 제약을 보호한다. 제거는
63
62
  // 손작성 마이그로 하고, 'gaon db diff' 가 계속 보여준다(조용히 넘어가지 않는다). ADD·SET
64
63
  // (unique·index·check·default 추가)은 자동 적용해 "조용한 no-op"(결정 220 배경)을 없앤다.
64
+ // 결정 366: 단 **재생성 쌍**(같은 이름 dropIndex→addIndex / dropCheck→addCheck — 인덱스
65
+ // method 변경·enum 값 변경)의 drop 반쪽은 defer 하지 않는다 — kind 만 보고 걸러내면
66
+ // add 반쪽이 기존 객체 위에 실행돼 "already exists" 로 migrate 가 매번 죽었다.
65
67
  const full = await computeMigration(target.db, target.tables, target.dialect);
66
- const droppedTables = full.ops
68
+ const { apply: schemaOps, deferred } = partitionDeferredOps(full.ops);
69
+ const droppedTables = deferred
67
70
  .filter((o) => o.kind === 'dropTable')
68
71
  .map((o) => o.name);
69
- const deferredDrops = full.ops.filter((o) => o.kind !== 'dropTable' && DEFERRED_DROP_KINDS.has(o.kind));
70
- const schemaOps = full.ops.filter((o) => !DEFERRED_DROP_KINDS.has(o.kind));
72
+ const deferredDrops = deferred.filter((o) => o.kind !== 'dropTable');
71
73
  const plan = {
72
74
  ops: schemaOps,
73
75
  up: renderUp(schemaOps, target.dialect),
@@ -123,16 +125,14 @@ export async function runDbMigrate(opts) {
123
125
  ` → 기존 DB 를 이어 쓰고 있었다면, 먼저 'gaon db diff' 로 계획을 확인하세요.`
124
126
  : '';
125
127
  // 3) schema-diff apply — up 을 순차 적용하고 이력 한 행(kind='schema')을 남긴다.
126
- await ensureJournal(target.db);
128
+ await ensureJournal(target.db, target.adapter);
127
129
  const id = batchId(plan.up.length);
128
130
  const summary = JSON.stringify(ops);
129
131
  const downSql = JSON.stringify(plan.down);
130
132
  const applySchema = async (db) => {
131
- for (const stmt of plan.up) {
132
- if (stmt.trim().startsWith('--'))
133
- continue;
134
- await sql.raw(stmt).execute(db);
135
- }
133
+ // 결정 374: raw 실행 대신 applyStatements 경유 — 비호환 타입 변경(F5 · 결정 275)
134
+ // 수리 안내(enrichAlterError · USING 절 예시)가 정본 CLI 경로에서도 나온다.
135
+ await applyStatements(db, [...plan.up]);
136
136
  await recordEntry(db, {
137
137
  id,
138
138
  kind: 'schema',
package/dist/db/replay.js CHANGED
@@ -71,7 +71,7 @@ export async function replayPending(opts) {
71
71
  if (opts.dryRun || pending.length === 0) {
72
72
  return { applied: [], pending, all };
73
73
  }
74
- await ensureJournal(opts.db);
74
+ await ensureJournal(opts.db, opts.adapter);
75
75
  const applied = [];
76
76
  for (const file of pending) {
77
77
  const mig = await loadMigration(opts.cwd, file);
@@ -1,4 +1,4 @@
1
- import { type ConnectionConfig, type TableDef } from '@gaonjs/data';
1
+ import { type ConnectionConfig, type SqlConnectionConfig, type TableDef } from '@gaonjs/data';
2
2
  import type { Kysely } from 'kysely';
3
3
  import { type Dialect, type AdapterName } from '@gaonjs/data';
4
4
  export interface ResolveDbOptions {
@@ -48,4 +48,14 @@ export interface ConnectionKeysByKind {
48
48
  * config 선언이 없으면(env-only) SQL ['main'] 폴백 — listConnectionKeys 와 정합.
49
49
  */
50
50
  export declare function listConnectionKeysByKind(cwd: string, configPath?: string): Promise<ConnectionKeysByKind>;
51
+ /**
52
+ * 결정 367: 선언된 **전 SQL 커넥션**의 설정 목록 — `gaon db seed` 가 시드 실행 전에
53
+ * 전부 등록하는 데 쓴다(시드는 커넥션을 몰라도 된다 · §10.1 — 모델이 자기 커넥션에
54
+ * 자동 바인딩). config 선언이 없으면(env-only) GAON_DATABASE_URL 기반 main 하나.
55
+ * 문서형(mongodb)은 제외(§7.4 — gaon db 는 SQL 축).
56
+ */
57
+ export declare function listSqlConnectionConfigs(cwd: string, configPath?: string): Promise<Array<{
58
+ key: string;
59
+ cfg: SqlConnectionConfig;
60
+ }>>;
51
61
  export declare function resolveDbTarget(opts: ResolveDbOptions): Promise<ResolvedDbTarget>;
@@ -119,6 +119,23 @@ export async function listConnectionKeysByKind(cwd, configPath) {
119
119
  }
120
120
  return { sql, mongo };
121
121
  }
122
+ /**
123
+ * 결정 367: 선언된 **전 SQL 커넥션**의 설정 목록 — `gaon db seed` 가 시드 실행 전에
124
+ * 전부 등록하는 데 쓴다(시드는 커넥션을 몰라도 된다 · §10.1 — 모델이 자기 커넥션에
125
+ * 자동 바인딩). config 선언이 없으면(env-only) GAON_DATABASE_URL 기반 main 하나.
126
+ * 문서형(mongodb)은 제외(§7.4 — gaon db 는 SQL 축).
127
+ */
128
+ export async function listSqlConnectionConfigs(cwd, configPath) {
129
+ const { config } = await loadDbConfig(resolvePath(cwd), configPath);
130
+ const entries = Object.entries(config.db ?? {});
131
+ if (entries.length === 0) {
132
+ const url = env.optional('GAON_DATABASE_URL');
133
+ return url ? [{ key: 'main', cfg: dbConfigFromUrl(url) }] : [];
134
+ }
135
+ return entries
136
+ .filter(([, cfg]) => cfg?.adapter !== 'mongodb')
137
+ .map(([key, cfg]) => ({ key, cfg: cfg }));
138
+ }
122
139
  export async function resolveDbTarget(opts) {
123
140
  const cwd = resolvePath(opts.cwd);
124
141
  // 사용자가 --config <path> 를 준 경우 그 경로에서 default export 를 로드한다.
@@ -153,9 +170,14 @@ export async function resolveDbTarget(opts) {
153
170
  // 결정 279(§7.4): 문서형(mongodb) 커넥션은 마이그레이션이 없다 — SQL 전용 경로(diff/migrate/
154
171
  // introspect)로 흘리지 않고 명확히 안내한다. 몽고 인덱스는 Mongoose 스키마 선언으로 관리한다.
155
172
  if (connCfg.adapter === 'mongodb') {
173
+ // 결정 384: 안내문을 실동작(결정 333·365)으로 정정 — 이전 문구("autoIndex 기본값이
174
+ // 부팅 시 보장 · dev·prod 공통")는 운영 autoIndex=false 이후 거짓 안내였다.
156
175
  throw new Error(`[gaon db] 커넥션 '${dbKey}' 은 문서형(mongodb)입니다 — 문서형은 마이그레이션이 없습니다(§7.4).\n` +
157
- ` → 몽고 인덱스는 Mongoose 스키마 선언(index:true / schema.index())으로 관리합니다(mongoose autoIndex 기본값이 부팅 보장).\n` +
158
- ` gaon db diff/migrate 는 SQL 커넥션(postgres·mysql)에만 실행하세요(키 생략 순회가 문서형을 자동으로 건너뜁니다 · 결정 292).`);
176
+ ` → 몽고 인덱스는 Mongoose 스키마 선언(index:true / schema.index())으로 관리합니다 dev·test autoIndex 가\n` +
177
+ ` 사용 만들고, 운영(NODE_ENV=production) autoIndex 기본 false 배포 절차에서 syncMongoIndexes() 로\n` +
178
+ ` 명시 동기화합니다(결정 333·365).\n` +
179
+ ` → gaon db 서브커맨드(diff/migrate/status/seed/reset)는 SQL 커넥션(postgres·mysql) 전용입니다 —\n` +
180
+ ` 키 생략 시 순회가 문서형을 자동으로 건너뜁니다(결정 292).`);
159
181
  }
160
182
  const db = createDb(connCfg);
161
183
  registerConnection(dbKey, db, connCfg.adapter);
package/dist/db/status.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // db/migrations/*.ts 각각의 적용/미적용과, 스키마↔DB 사이에 아직 반영 안 된
4
4
  // 변경(schema drift) 수를 함께 보여준다. 아무것도 실행하지 않는다.
5
- import { computeMigration, summarizeOp, DEFERRED_DROP_KINDS } from '@gaonjs/data';
5
+ import { computeMigration, summarizeOp, partitionDeferredOps } from '@gaonjs/data';
6
6
  import { resolveDbTarget } from './resolve.js';
7
7
  import { appliedFileMigrations } from './journal.js';
8
8
  import { describeDeferred } from './migrate.js';
@@ -21,14 +21,17 @@ export async function runDbStatus(opts) {
21
21
  // migrate 는 스키마에 없는 테이블을 자동 DROP 하지 않으므로(결정 39 · §4.8)
22
22
  // dropTable 은 drift 로 세지 않는다 — 별도로 참고 정보(extraTables)로만 보고.
23
23
  const plan = await computeMigration(target.db, target.tables, target.dialect);
24
- const extraTables = plan.ops
25
- .filter((o) => o.kind === 'dropTable')
26
- .map((o) => o.name);
27
24
  // 결정 326: deferred aux drop(제약·인덱스·기본값 제거)도 migrate 가 자동 적용하지
28
25
  // 않는다(결정 220) — drift 로 세면 "'gaon db migrate' 로 반영하세요" 가 영원히 안
29
26
  // 지워지는 거짓 안내가 된다. dropTable 과 같은 축의 참고 항목으로 분리한다.
30
- const deferredDrops = plan.ops.filter((o) => o.kind !== 'dropTable' && DEFERRED_DROP_KINDS.has(o.kind));
31
- const schemaChanges = plan.ops.length - extraTables.length - deferredDrops.length;
27
+ // 결정 366: 재생성 쌍(인덱스 method·enum 값 변경) drop 반쪽은 migrate 자동
28
+ // 적용하므로 deferred 가 아니라 drift(schemaChanges)로 계상한다 migrate 판정.
29
+ const { apply, deferred } = partitionDeferredOps(plan.ops);
30
+ const extraTables = deferred
31
+ .filter((o) => o.kind === 'dropTable')
32
+ .map((o) => o.name);
33
+ const deferredDrops = deferred.filter((o) => o.kind !== 'dropTable');
34
+ const schemaChanges = apply.length;
32
35
  const lines = [];
33
36
  if (migrations.length === 0) {
34
37
  lines.push(` [${opts.dbKey}] 마이그레이션 파일 없음 (db/migrations/).`);
package/dist/db.js CHANGED
@@ -11,7 +11,7 @@ import { join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { createDb, registerConnection, destroyAllConnections, isSeedDef, } from '@gaonjs/data';
13
13
  import { registerTsResolve } from './tsResolve.js';
14
- import { resolveDbTarget } from './db/resolve.js';
14
+ import { resolveDbTarget, listSqlConnectionConfigs } from './db/resolve.js';
15
15
  import { registerInProjectData, isSplitDataInstance } from './db/projectData.js';
16
16
  /** DB URL 에서 어댑터를 추정한다(gaon work 와 동일 규칙). */
17
17
  function dbConfigFromUrl(url) {
@@ -77,15 +77,36 @@ export async function runDbSeedCommand(opts = {}) {
77
77
  throw new Error(`[gaon db seed] 커넥션 '${dbKey}' 에 url 이 없어 시드를 실행할 수 없습니다.\n` +
78
78
  ` → gaon.config.ts 의 db.${dbKey} 에 url 을 두거나 .env 의 GAON_DATABASE_URL 을 설정하세요.`);
79
79
  }
80
- const projData = await registerInProjectData(root, dbKey, { adapter: target.adapter, url: target.url, poolMax: 1 });
80
+ // 결정 367: 시드는 커넥션을 몰라도 된다(§10.1 모델이 자기 커넥션에 자동 바인딩).
81
+ // dbKey 하나만 등록하면 ≥2 SQL 커넥션 프로젝트의 시드가 타 커넥션 모델의 '미등록'
82
+ // throw 로 항상 실패했다(그리고 그 에러가 결정 156 오진 안내로 이어졌다) — 선언된
83
+ // **전 SQL 커넥션**을 등록하고 시드는 1회만 실행한다. url 없는 보조 커넥션은 연결
84
+ // 불가라 건너뛴다(그 커넥션 모델을 시드가 쓰면 미등록 에러가 원인을 가리킨다).
85
+ const others = (await listSqlConnectionConfigs(root, opts.configPath)).filter((c) => c.key !== dbKey && ('url' in c.cfg ? c.cfg.url : true));
86
+ const registered = [dbKey];
87
+ const projHandles = [];
88
+ const primaryHandle = await registerInProjectData(root, dbKey, { adapter: target.adapter, url: target.url, poolMax: 1 });
89
+ if (primaryHandle)
90
+ projHandles.push(primaryHandle);
91
+ for (const { key, cfg } of others) {
92
+ registerConnection(key, createDb(cfg), cfg.adapter);
93
+ const h = await registerInProjectData(root, key, { ...cfg, poolMax: 1 });
94
+ if (h)
95
+ projHandles.push(h);
96
+ registered.push(key);
97
+ }
81
98
  try {
82
99
  const def = await loadSeed(root);
83
100
  await runSeed(def, root);
84
- return { exitCode: 0, ...SEED_OK };
101
+ return {
102
+ exitCode: 0,
103
+ text: `${SEED_OK.text} (커넥션: ${registered.join(', ')})`,
104
+ json: { command: 'seed', ok: true, connections: registered },
105
+ };
85
106
  }
86
107
  finally {
87
- if (projData)
88
- await projData.close();
108
+ for (const h of projHandles)
109
+ await h.close();
89
110
  await target.close();
90
111
  }
91
112
  }
package/dist/dev.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { readdirSync, existsSync } from 'node:fs';
15
15
  import { join, resolve } from 'node:path';
16
- import { analyzeProjectI18n } from './i18n-config.js';
16
+ import { analyzeProjectI18n, resolveLocalesDir } from './i18n-config.js';
17
17
  /** locales/*.json 변경(메시지 카탈로그) — messages.d.ts 재생성 트리거. */
18
18
  const isMessagesChange = (f) => !f.includes('.gaon') && f.endsWith('.json');
19
19
  const isSchemaChange = (f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts') && !f.includes('.gaon');
@@ -148,7 +148,7 @@ export function resolveDevLayout(cwd) {
148
148
  // 결정 352: 카탈로그 위치·기준 로케일은 gaon.config.ts 의 i18n 블록을 따른다
149
149
  // (정적 분석 · 하드코딩 'locales' 는 dir 커스텀 프로젝트에서 타입 축을 무소음으로 껐다).
150
150
  const i18nCfg = analyzeProjectI18n(root);
151
- const localesDirPath = join(root, i18nCfg.dir ?? 'locales');
151
+ const localesDirPath = resolveLocalesDir(root, i18nCfg.dir);
152
152
  return {
153
153
  schemaDir: existsSync(schemaDirPath) ? schemaDirPath : undefined,
154
154
  tablesOut: join(root, '.gaon', 'tables.d.ts'),
@@ -12,6 +12,7 @@ import { readdir, readFile } from 'node:fs/promises';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join, relative } from 'node:path';
14
14
  import { stripComments, stripCommentsAndStrings } from './source-scan.js';
15
+ import { jwtSecretEnvFor } from '../generate.js';
15
16
  // 결정 140(12차 W1): 판정 전 주석/문자열을 지운다. `// session:` 주석이나 문자열
16
17
  // 안의 우연한 일치가 배선으로 오탐되면 보안 검사(세션·CSRF)가 통째로 skip 된다.
17
18
  /** 컨트롤러 소스가 인증 표면을 쓰는지 판정한다(단위 테스트 진입점). */
@@ -57,10 +58,12 @@ export async function checkAuthWiring(cwd) {
57
58
  file: authFiles[0],
58
59
  message: `인증 배선 누락: apps/${app} 컨트롤러(${authFiles.join(', ')})가 requireAuth/this.auth 를 쓰는데 ` +
59
60
  `${acRel} 에 auth 배선이 없습니다. 이대로면 로그인해도 currentUser 가 항상 null 입니다(결정 59).\n` +
60
- `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
61
+ `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요(세션 앱 · 정본):\n` +
61
62
  ` session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' },\n` +
62
63
  ` auth: { loadUser, loginRedirect: '/session/new' } // import { loadUser } from './auth.js'\n` +
63
- `→ loadUser 없으면 \`gaon g auth --app ${app}\` 스캐폴드가 apps/${app}/auth.ts 에 만들어 줍니다.`,
64
+ `→ API(JWT) 앱이라면 세션 대신 토큰 배선입니다(결정 337·338 세션·loginRedirect 없음):\n` +
65
+ ` auth: { strategy: 'jwt', secret: process.env.${jwtSecretEnvFor(app)} ?? '32자 이상 dev 비밀', loadUser }\n` +
66
+ `→ loadUser 가 없으면 \`gaon g auth --app ${app}\`(세션) 또는 \`gaon g auth --jwt --app ${app}\`(JWT) 스캐폴드가 apps/${app}/auth.ts 에 만들어 줍니다.`,
64
67
  detail: { app, controllers: authFiles },
65
68
  });
66
69
  }
@@ -0,0 +1,9 @@
1
+ import type { RuleReport } from './types.js';
2
+ /**
3
+ * 소스가 default 를 다른 모듈에서 그대로 재수출만 하는지 판정하고 그 모듈 지정자를 낸다.
4
+ * `export { default } from '...'` · `export { default as default } from '...'` ·
5
+ * `export * from '...'` 를 인정한다(주석 제외 후 판정).
6
+ */
7
+ export declare function reexportSpecifier(source: string): string | undefined;
8
+ /** apps/ 를 훑어 앱간 동명 채널을 낸다. */
9
+ export declare function checkChannelCollision(cwd: string): Promise<RuleReport>;