@gaonjs/cli 0.3.0 → 0.5.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/__fixtures__/db-minimal/domain/schema/widgets.d.ts +12 -0
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.js +7 -0
- package/dist/__fixtures__/db-minimal/gaon.config.d.ts +2 -0
- package/dist/__fixtures__/db-minimal/gaon.config.js +11 -0
- package/dist/commands/check.d.ts +31 -0
- package/dist/commands/check.js +223 -0
- package/dist/commands/console.d.ts +46 -0
- package/dist/commands/console.js +129 -0
- package/dist/commands/db.d.ts +20 -0
- package/dist/commands/db.js +74 -0
- package/dist/commands/dev.d.ts +68 -0
- package/dist/commands/dev.js +287 -0
- package/dist/commands/new.d.ts +45 -0
- package/dist/commands/new.js +274 -0
- package/dist/commands/test.d.ts +11 -0
- package/dist/commands/test.js +119 -0
- package/dist/db/diff.d.ts +17 -0
- package/dist/db/diff.js +57 -0
- package/dist/db/index.d.ts +4 -0
- package/dist/db/index.js +8 -0
- package/dist/db/migrate.d.ts +16 -0
- package/dist/db/migrate.js +173 -0
- package/dist/db/reset.d.ts +18 -0
- package/dist/db/reset.js +150 -0
- package/dist/db/resolve.d.ts +32 -0
- package/dist/db/resolve.js +130 -0
- package/dist/dev/console.d.ts +39 -0
- package/dist/dev/console.js +100 -0
- package/dist/dev/docker.d.ts +52 -0
- package/dist/dev/docker.js +163 -0
- package/dist/dev/index.d.ts +14 -0
- package/dist/dev/index.js +10 -0
- package/dist/dev/tsc.d.ts +41 -0
- package/dist/dev/tsc.js +127 -0
- package/dist/dev/watcher.d.ts +50 -0
- package/dist/dev/watcher.js +95 -0
- package/dist/dev.d.ts +1 -16
- package/dist/dev.js +10 -66
- package/dist/doctor/no-auto-import.d.ts +10 -0
- package/dist/doctor/no-auto-import.js +158 -0
- package/dist/doctor/reporter.d.ts +1 -1
- package/dist/doctor/reporter.js +16 -3
- package/dist/doctor/setup.d.ts +26 -0
- package/dist/doctor/setup.js +52 -0
- package/dist/doctor/shared-composable-purity.d.ts +8 -0
- package/dist/doctor/shared-composable-purity.js +164 -0
- package/dist/doctor/types.d.ts +14 -1
- package/dist/doctor/types.js +10 -5
- package/dist/doctor.d.ts +21 -5
- package/dist/doctor.js +77 -8
- package/dist/index.d.ts +9 -2
- package/dist/index.js +175 -27
- package/dist/templates/index.d.ts +23 -0
- package/dist/templates/index.js +66 -0
- package/dist/templates/index.ts +85 -0
- package/dist/templates/project/.env.example.tpl +18 -0
- package/dist/templates/project/.gitignore.tpl +24 -0
- package/dist/templates/project/.npmrc.tpl +4 -0
- package/dist/templates/project/CLAUDE.md.tpl +119 -0
- package/dist/templates/project/apps/web/channels/.gitkeep.tpl +1 -0
- package/dist/templates/project/apps/web/components/.gitkeep.tpl +1 -0
- package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +25 -0
- package/dist/templates/project/apps/web/controllers/home.ts.tpl +19 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +43 -0
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +36 -0
- package/dist/templates/project/apps/web/routes.ts.tpl +8 -0
- package/dist/templates/project/docker-compose.yaml.tpl +73 -0
- package/dist/templates/project/domain/events/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/jobs/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/listeners/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/mails/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/models/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/schema/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/services/.gitkeep.tpl +1 -0
- package/dist/templates/project/gaon.config.ts.tpl +27 -0
- package/dist/templates/project/package.json.tpl +27 -0
- package/dist/templates/project/pnpm-workspace.yaml.tpl +11 -0
- package/dist/templates/project/shared/components/.gitkeep.tpl +1 -0
- package/dist/templates/project/shared/composables/useDebounce.ts.tpl +21 -0
- package/dist/templates/project/tsconfig.json.tpl +25 -0
- package/package.json +5 -5
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gaonjs/cli · `gaon test` — vitest wrapper (M9-G · v0.15 §13.5)
|
|
3
|
+
*
|
|
4
|
+
* The One Way — 하나의 명령이 vitest 를 얇게 감싼다. 사용자는
|
|
5
|
+
* `gaon test` 로 전체를, `gaon test posts` 로 필터를, `gaon test --unit`
|
|
6
|
+
* 으로 단위만 실행한다. 나머지는 전부 vitest 에 그대로 위임 — 우리가
|
|
7
|
+
* 관례를 재발명하지 않는다.
|
|
8
|
+
*
|
|
9
|
+
* 스코프 필터(§9 실 인프라 관례):
|
|
10
|
+
* --unit *.test.ts (통합 제외)
|
|
11
|
+
* --integration *.integration.test.ts 만
|
|
12
|
+
* (기본) 둘 다 실행
|
|
13
|
+
*
|
|
14
|
+
* 실행 경로 우선순위:
|
|
15
|
+
* 1) 사용자 package.json 의 `test` 스크립트가 있으면 `pnpm test -- <args>`
|
|
16
|
+
* 2) 로컬 node_modules/.bin/vitest 가 있으면 직접 spawn(`run` 모드)
|
|
17
|
+
* 3) 둘 다 없으면 exit 127 + 설치 안내
|
|
18
|
+
*
|
|
19
|
+
* §9 실 인프라 · 목업 X — 실 vitest 프로세스를 spawn 한다.
|
|
20
|
+
* exit code · signal 은 vitest 그대로 propagate.
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
/** 사용자 프로젝트에 `test` 스크립트가 있는지. */
|
|
26
|
+
function hasTestScript(cwd) {
|
|
27
|
+
const pkgPath = join(cwd, 'package.json');
|
|
28
|
+
if (!existsSync(pkgPath))
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
32
|
+
return typeof pkg.scripts?.test === 'string' && pkg.scripts.test.length > 0;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 스코프 → vitest CLI 인자로 매핑. vitest 는 위치 인자를 파일 패턴으로
|
|
40
|
+
* 해석한다(맨 뒤에 붙는 값이 include 필터). --include 플래그는 vitest
|
|
41
|
+
* CLI 에 없다 — 그래서 unit 은 --exclude 로, integration 은 위치 인자
|
|
42
|
+
* 패턴으로 처리한다.
|
|
43
|
+
*/
|
|
44
|
+
function scopeArgs(scope) {
|
|
45
|
+
if (scope === 'unit') {
|
|
46
|
+
// 통합 테스트 제외 — vitest 의 --exclude 로 스킵.
|
|
47
|
+
return ['--exclude', '**/*.integration.test.ts'];
|
|
48
|
+
}
|
|
49
|
+
if (scope === 'integration') {
|
|
50
|
+
// integration 만 — 위치 인자 필터(파일 경로 substring 매칭).
|
|
51
|
+
return ['integration.test'];
|
|
52
|
+
}
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
|
|
57
|
+
* pnpm test 는 pnpm 관례상 `pnpm test -- <args>` 로 넘겨야 vitest 까지 도달.
|
|
58
|
+
*/
|
|
59
|
+
export async function runTestCommand(args = [], opts = {}) {
|
|
60
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
61
|
+
const scope = opts.scope ?? 'all';
|
|
62
|
+
const json = opts.json ?? false;
|
|
63
|
+
const scopeExtras = scopeArgs(scope);
|
|
64
|
+
const passthrough = [...scopeExtras, ...args];
|
|
65
|
+
let cmd;
|
|
66
|
+
let spawnArgs;
|
|
67
|
+
if (hasTestScript(cwd)) {
|
|
68
|
+
cmd = 'pnpm';
|
|
69
|
+
// pnpm test -- <extras> — `--` 로 뒤 인자를 스크립트에 전달.
|
|
70
|
+
spawnArgs = ['test', ...(passthrough.length ? ['--', ...passthrough] : [])];
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const vitestBin = join(cwd, 'node_modules', '.bin', 'vitest');
|
|
74
|
+
if (!existsSync(vitestBin)) {
|
|
75
|
+
const msg = `vitest 가 설치돼 있지 않고 package.json 에 "test" 스크립트도 없습니다.\n` +
|
|
76
|
+
`→ pnpm add -D vitest 후 다시 실행하거나, package.json 에 "test": "vitest run" 을 추가하세요.`;
|
|
77
|
+
if (json) {
|
|
78
|
+
process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
process.stderr.write(` ✗ ${msg}\n`);
|
|
82
|
+
}
|
|
83
|
+
return 127;
|
|
84
|
+
}
|
|
85
|
+
cmd = vitestBin;
|
|
86
|
+
spawnArgs = ['run', ...passthrough];
|
|
87
|
+
}
|
|
88
|
+
if (json) {
|
|
89
|
+
process.stdout.write(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope }) + '\n');
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
process.stdout.write(` gaon test · ${cmd} ${spawnArgs.join(' ')} (scope=${scope})\n`);
|
|
93
|
+
}
|
|
94
|
+
const exitCode = await new Promise((resolvePromise) => {
|
|
95
|
+
const child = spawn(cmd, spawnArgs, {
|
|
96
|
+
cwd,
|
|
97
|
+
env: process.env,
|
|
98
|
+
// vitest 컬러 출력·리포터를 그대로 보여주기 위해 stdio 를 상속한다.
|
|
99
|
+
stdio: 'inherit',
|
|
100
|
+
});
|
|
101
|
+
child.on('error', (err) => {
|
|
102
|
+
process.stderr.write(` ✗ gaon test spawn 실패: ${String(err)}\n`);
|
|
103
|
+
resolvePromise(127);
|
|
104
|
+
});
|
|
105
|
+
child.on('close', (code, signal) => {
|
|
106
|
+
if (signal) {
|
|
107
|
+
// SIGINT 등으로 종료된 경우 — 130(SIGINT) 관례.
|
|
108
|
+
resolvePromise(signal === 'SIGINT' ? 130 : 1);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
resolvePromise(code ?? 1);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
if (json) {
|
|
116
|
+
process.stdout.write(JSON.stringify({ kind: 'exited', exitCode }) + '\n');
|
|
117
|
+
}
|
|
118
|
+
return exitCode;
|
|
119
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface DbDiffOptions {
|
|
2
|
+
readonly cwd: string;
|
|
3
|
+
readonly dbKey: string;
|
|
4
|
+
readonly json: boolean;
|
|
5
|
+
readonly configPath?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DbDiffResult {
|
|
8
|
+
readonly exitCode: number;
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly json: unknown;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* `gaon db diff` — 커넥션 대비 스키마 diff 를 계산해 텍스트·JSON 을 낳는다.
|
|
14
|
+
* 아무것도 적용하지 않는다. exitCode 는 항상 0(변경 없음도 성공 · CI 에서
|
|
15
|
+
* 변경 존재 여부만 보고 싶으면 --json 의 changes 를 읽어라).
|
|
16
|
+
*/
|
|
17
|
+
export declare function runDbDiff(opts: DbDiffOptions): Promise<DbDiffResult>;
|
package/dist/db/diff.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @gaonjs/cli · `gaon db diff` — 스키마(desired) ↔ DB(current) 차이 계산 (M9-D)
|
|
2
|
+
//
|
|
3
|
+
// 순수 diff — 아무것도 실행하지 않는다(SQL 만 출력). computeMigration 이
|
|
4
|
+
// 방언별 up/down 을 이미 만든다(@gaonjs/data). 이 함수는 배선·표현 담당.
|
|
5
|
+
import { computeMigration } from '@gaonjs/data';
|
|
6
|
+
import { resolveDbTarget } from './resolve.js';
|
|
7
|
+
function opSummary(op) {
|
|
8
|
+
switch (op.kind) {
|
|
9
|
+
case 'createTable':
|
|
10
|
+
return { kind: op.kind, table: op.table.name };
|
|
11
|
+
case 'dropTable':
|
|
12
|
+
return { kind: op.kind, table: op.name };
|
|
13
|
+
case 'addColumn':
|
|
14
|
+
case 'dropColumn':
|
|
15
|
+
case 'alterColumn':
|
|
16
|
+
return { kind: op.kind, table: op.table, column: op.column };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `gaon db diff` — 커넥션 대비 스키마 diff 를 계산해 텍스트·JSON 을 낳는다.
|
|
21
|
+
* 아무것도 적용하지 않는다. exitCode 는 항상 0(변경 없음도 성공 · CI 에서
|
|
22
|
+
* 변경 존재 여부만 보고 싶으면 --json 의 changes 를 읽어라).
|
|
23
|
+
*/
|
|
24
|
+
export async function runDbDiff(opts) {
|
|
25
|
+
const target = await resolveDbTarget({
|
|
26
|
+
cwd: opts.cwd,
|
|
27
|
+
dbKey: opts.dbKey,
|
|
28
|
+
configPath: opts.configPath,
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
const plan = await computeMigration(target.db, target.tables, target.dialect);
|
|
32
|
+
const ops = plan.ops.map(opSummary);
|
|
33
|
+
const json = {
|
|
34
|
+
command: 'diff',
|
|
35
|
+
db: opts.dbKey,
|
|
36
|
+
changes: ops.length,
|
|
37
|
+
ops,
|
|
38
|
+
up: plan.up,
|
|
39
|
+
down: plan.down,
|
|
40
|
+
};
|
|
41
|
+
const text = ops.length === 0
|
|
42
|
+
? ` [${opts.dbKey}] 변경 없음 — 스키마와 DB 가 일치합니다.`
|
|
43
|
+
: ` [${opts.dbKey}] ${ops.length}개 변경\n` +
|
|
44
|
+
` -- up --\n${indent(plan.up.join('\n'))}\n` +
|
|
45
|
+
` -- down --\n${indent(plan.down.join('\n'))}`;
|
|
46
|
+
return { exitCode: 0, text, json };
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await target.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function indent(text) {
|
|
53
|
+
return text
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((l) => (l ? ` ${l}` : l))
|
|
56
|
+
.join('\n');
|
|
57
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { runDbDiff, type DbDiffOptions, type DbDiffResult, } from './diff.js';
|
|
2
|
+
export { runDbMigrate, type DbMigrateOptions, type DbMigrateResult, } from './migrate.js';
|
|
3
|
+
export { runDbReset, type DbResetOptions, type DbResetResult, } from './reset.js';
|
|
4
|
+
export { resolveDbTarget, type ResolveDbOptions, type ResolvedDbTarget, } from './resolve.js';
|
package/dist/db/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @gaonjs/cli · gaon db 서브커맨드 공용 모듈 (M9-D)
|
|
2
|
+
//
|
|
3
|
+
// dispatcher 는 commands/db.ts (runDbCommand) 가 담당하고, 여기서는 개별
|
|
4
|
+
// 서브커맨드 로직·타입만 노출한다. seed 는 기존 db.ts (M8) 를 그대로 위임.
|
|
5
|
+
export { runDbDiff, } from './diff.js';
|
|
6
|
+
export { runDbMigrate, } from './migrate.js';
|
|
7
|
+
export { runDbReset, } from './reset.js';
|
|
8
|
+
export { resolveDbTarget, } from './resolve.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface DbMigrateOptions {
|
|
2
|
+
readonly cwd: string;
|
|
3
|
+
readonly dbKey: string;
|
|
4
|
+
readonly json: boolean;
|
|
5
|
+
readonly dryRun: boolean;
|
|
6
|
+
readonly configPath?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface DbMigrateResult {
|
|
9
|
+
readonly exitCode: number;
|
|
10
|
+
readonly text: string;
|
|
11
|
+
readonly json: unknown;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* `gaon db migrate` — 진짜 apply. 실 DB 필요(§9).
|
|
15
|
+
*/
|
|
16
|
+
export declare function runDbMigrate(opts: DbMigrateOptions): Promise<DbMigrateResult>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @gaonjs/cli · `gaon db migrate` — 스키마 diff 적용 + 이력 기록 (M9-D)
|
|
2
|
+
//
|
|
3
|
+
// 흐름:
|
|
4
|
+
// 1) resolveDbTarget → computeMigration 로 up/down·ops 산출.
|
|
5
|
+
// 2) --dry-run 이면 SQL 만 출력하고 종료(실행 X).
|
|
6
|
+
// 3) 트랜잭션 안에서 up 을 순차 실행하고, 완료 후 _gaon_migrations 에
|
|
7
|
+
// 배치 한 행을 남긴다(감사 로그 — 스냅샷 기반 diff 는 재실행 시
|
|
8
|
+
// idempotent 지만, 이력은 언제 무엇이 적용됐는지를 남긴다).
|
|
9
|
+
// 4) journal 미존재 상태에서 createTable 이 포함되면 §7.5.3 방식 안내를
|
|
10
|
+
// 함께 낸다(baseline · 사용자가 기존 DB 를 갖고 있다면 diff 를 먼저
|
|
11
|
+
// 확인하도록).
|
|
12
|
+
//
|
|
13
|
+
// 트랜잭션: postgres 는 트랜잭셔널 DDL 이라 안전, mysql/mariadb 는 DDL 이
|
|
14
|
+
// autocommit 이라 실패 시 부분 적용이 남을 수 있다 — mysql 에서는 이력 행
|
|
15
|
+
// 삽입만 트랜잭션에 감싼다(에러 안내로 사용자에게 알린다).
|
|
16
|
+
import { sql } from 'kysely';
|
|
17
|
+
import { computeMigration, MIGRATIONS_TABLE } from '@gaonjs/data';
|
|
18
|
+
import { resolveDbTarget } from './resolve.js';
|
|
19
|
+
function opSummary(op) {
|
|
20
|
+
switch (op.kind) {
|
|
21
|
+
case 'createTable':
|
|
22
|
+
return { kind: op.kind, table: op.table.name };
|
|
23
|
+
case 'dropTable':
|
|
24
|
+
return { kind: op.kind, table: op.name };
|
|
25
|
+
case 'addColumn':
|
|
26
|
+
case 'dropColumn':
|
|
27
|
+
case 'alterColumn':
|
|
28
|
+
return { kind: op.kind, table: op.table, column: op.column };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** _gaon_migrations 존재 여부. information_schema 조회로 방언 무관하게 검사. */
|
|
32
|
+
async function journalExists(db) {
|
|
33
|
+
const rows = await sql `
|
|
34
|
+
select count(*)::text as n
|
|
35
|
+
from information_schema.tables
|
|
36
|
+
where table_name = ${MIGRATIONS_TABLE}
|
|
37
|
+
`.execute(db);
|
|
38
|
+
const first = rows.rows[0];
|
|
39
|
+
if (!first)
|
|
40
|
+
return false;
|
|
41
|
+
return Number(first.n) > 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* _gaon_migrations 를 만든다(존재하면 no-op). 컬럼:
|
|
45
|
+
* id 문자열 PK — 배치 식별자 (`<epochMs>-<적용문수>`).
|
|
46
|
+
* applied_at 타임스탬프 — 서버 기준 시각.
|
|
47
|
+
* db_key 커넥션 키 (§4.5).
|
|
48
|
+
* statements 이 배치의 SQL 문 수.
|
|
49
|
+
* summary JSON 문자열(ops 요약).
|
|
50
|
+
* text/varchar/timestamp/integer 는 postgres·mysql 공통 지원 타입.
|
|
51
|
+
*/
|
|
52
|
+
async function ensureJournal(db) {
|
|
53
|
+
// postgres·mysql 공통 CREATE TABLE IF NOT EXISTS.
|
|
54
|
+
// 기본값 표현은 각 방언이 자동으로 처리(now()/CURRENT_TIMESTAMP 모두 표준).
|
|
55
|
+
await sql.raw(`create table if not exists ${MIGRATIONS_TABLE} (` +
|
|
56
|
+
` id varchar(64) not null primary key,` +
|
|
57
|
+
` applied_at timestamp not null,` +
|
|
58
|
+
` db_key varchar(64) not null,` +
|
|
59
|
+
` statements integer not null,` +
|
|
60
|
+
` summary text not null` +
|
|
61
|
+
`)`).execute(db);
|
|
62
|
+
}
|
|
63
|
+
/** 배치 식별자 — 초 단위 epoch + 적용 문수. 사람도 읽고 정렬도 된다. */
|
|
64
|
+
function batchId(count) {
|
|
65
|
+
return `${Date.now()}-${count}`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `gaon db migrate` — 진짜 apply. 실 DB 필요(§9).
|
|
69
|
+
*/
|
|
70
|
+
export async function runDbMigrate(opts) {
|
|
71
|
+
const target = await resolveDbTarget({
|
|
72
|
+
cwd: opts.cwd,
|
|
73
|
+
dbKey: opts.dbKey,
|
|
74
|
+
configPath: opts.configPath,
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
const plan = await computeMigration(target.db, target.tables, target.dialect);
|
|
78
|
+
const ops = plan.ops.map(opSummary);
|
|
79
|
+
// 아무것도 적용할 게 없다 — 성공으로 취급(스냅샷 diff 관례).
|
|
80
|
+
if (plan.up.length === 0) {
|
|
81
|
+
return {
|
|
82
|
+
exitCode: 0,
|
|
83
|
+
text: ` [${opts.dbKey}] 적용할 마이그레이션이 없습니다 — 스키마와 DB 가 일치합니다.`,
|
|
84
|
+
json: {
|
|
85
|
+
command: 'migrate',
|
|
86
|
+
db: opts.dbKey,
|
|
87
|
+
applied: 0,
|
|
88
|
+
ops: [],
|
|
89
|
+
dryRun: opts.dryRun,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// --dry-run: SQL 만 출력. 이력 테이블도 만들지 않는다.
|
|
94
|
+
if (opts.dryRun) {
|
|
95
|
+
return {
|
|
96
|
+
exitCode: 0,
|
|
97
|
+
text: ` [${opts.dbKey}] --dry-run · 적용하지 않고 SQL 만 출력합니다 (${plan.up.length}문)\n` +
|
|
98
|
+
plan.up.map((s) => ` ${s}`).join('\n'),
|
|
99
|
+
json: {
|
|
100
|
+
command: 'migrate',
|
|
101
|
+
db: opts.dbKey,
|
|
102
|
+
applied: 0,
|
|
103
|
+
ops,
|
|
104
|
+
up: plan.up,
|
|
105
|
+
dryRun: true,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// baseline 감지 — journal 이 없는데 createTable 이 포함 → 안내(§7.5.3).
|
|
110
|
+
// 에러가 아니라 정보성 노트로 처리(실행은 진행). 사용자가 기존 DB 를 갖고
|
|
111
|
+
// 있다면 diff 로 먼저 계획을 봐야 한다.
|
|
112
|
+
const hadJournal = await journalExists(target.db);
|
|
113
|
+
const hasCreate = plan.ops.some((o) => o.kind === 'createTable');
|
|
114
|
+
const baselineNote = !hadJournal && hasCreate
|
|
115
|
+
? ` ℹ 최초 마이그레이션(_gaon_migrations 미존재) — 이 배치가 baseline 이 됩니다.\n` +
|
|
116
|
+
` → 기존 DB 를 이어 쓰고 있었다면, 먼저 'gaon db diff' 로 계획을 확인하세요.\n` +
|
|
117
|
+
` → 이미 존재하는 테이블은 스키마와 일치해야 합니다(불일치 시 alterColumn 이 함께 실행됩니다).`
|
|
118
|
+
: '';
|
|
119
|
+
// apply — up 을 순차 적용하고 이력 한 행을 남긴다. postgres 는 트랜잭셔널 DDL.
|
|
120
|
+
// ensureJournal 은 트랜잭션 밖에서(mysql 호환) 만들고, insert 만 트랜잭션 안.
|
|
121
|
+
await ensureJournal(target.db);
|
|
122
|
+
const id = batchId(plan.up.length);
|
|
123
|
+
const summary = JSON.stringify(ops);
|
|
124
|
+
if (target.adapter === 'postgres') {
|
|
125
|
+
// postgres: DDL + insert 를 한 트랜잭션에 넣는다(원자적).
|
|
126
|
+
await target.db.transaction().execute(async (trx) => {
|
|
127
|
+
for (const stmt of plan.up) {
|
|
128
|
+
if (stmt.trim().startsWith('--'))
|
|
129
|
+
continue;
|
|
130
|
+
await sql.raw(stmt).execute(trx);
|
|
131
|
+
}
|
|
132
|
+
await sql `
|
|
133
|
+
insert into ${sql.ref(MIGRATIONS_TABLE)}
|
|
134
|
+
(id, applied_at, db_key, statements, summary)
|
|
135
|
+
values (${id}, ${new Date()}, ${opts.dbKey}, ${plan.up.length}, ${summary})
|
|
136
|
+
`.execute(trx);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// mysql/mariadb: DDL 은 autocommit 이라 트랜잭션에 감싸도 롤백되지 않는다.
|
|
141
|
+
// 정직하게 순차 실행하고, 이력만 별도로 남긴다(§4.5 방언 차이).
|
|
142
|
+
for (const stmt of plan.up) {
|
|
143
|
+
if (stmt.trim().startsWith('--'))
|
|
144
|
+
continue;
|
|
145
|
+
await sql.raw(stmt).execute(target.db);
|
|
146
|
+
}
|
|
147
|
+
await sql `
|
|
148
|
+
insert into ${sql.ref(MIGRATIONS_TABLE)}
|
|
149
|
+
(id, applied_at, db_key, statements, summary)
|
|
150
|
+
values (${id}, ${new Date()}, ${opts.dbKey}, ${plan.up.length}, ${summary})
|
|
151
|
+
`.execute(target.db);
|
|
152
|
+
}
|
|
153
|
+
const text = (baselineNote ? baselineNote + '\n' : '') +
|
|
154
|
+
` [${opts.dbKey}] ${plan.up.length}개 문 적용 완료 (batch ${id}).`;
|
|
155
|
+
return {
|
|
156
|
+
exitCode: 0,
|
|
157
|
+
text,
|
|
158
|
+
json: {
|
|
159
|
+
command: 'migrate',
|
|
160
|
+
db: opts.dbKey,
|
|
161
|
+
applied: plan.up.length,
|
|
162
|
+
ops,
|
|
163
|
+
up: plan.up,
|
|
164
|
+
batchId: id,
|
|
165
|
+
baseline: !hadJournal && hasCreate,
|
|
166
|
+
dryRun: false,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await target.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface DbResetOptions {
|
|
2
|
+
readonly cwd: string;
|
|
3
|
+
readonly dbKey: string;
|
|
4
|
+
readonly json: boolean;
|
|
5
|
+
readonly yes: boolean;
|
|
6
|
+
readonly dryRun: boolean;
|
|
7
|
+
readonly configPath?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DbResetResult {
|
|
10
|
+
readonly exitCode: number;
|
|
11
|
+
readonly text: string;
|
|
12
|
+
readonly json: unknown;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `gaon db reset` — 위험 명령. NODE_ENV=production 은 무조건 거부.
|
|
16
|
+
* --yes 로 실 파괴 확증. --dry-run 은 무엇을 지울지·만들지만 출력.
|
|
17
|
+
*/
|
|
18
|
+
export declare function runDbReset(opts: DbResetOptions): Promise<DbResetResult>;
|
package/dist/db/reset.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @gaonjs/cli · `gaon db reset` — 스키마 초기화 후 재마이그+시드 (M9-D)
|
|
2
|
+
//
|
|
3
|
+
// 위험 명령이라 fail-closed 로 잠근다(CLAUDE.md §8 보안 기본 켬):
|
|
4
|
+
// 1) NODE_ENV=production 이면 무조건 거부(옵션 무관).
|
|
5
|
+
// 2) --yes 없이는 실행하지 않는다(사용자 확증).
|
|
6
|
+
// 두 게이트 모두 통과 시 introspect → DROP ALL → 재마이그 → seed 순.
|
|
7
|
+
//
|
|
8
|
+
// seed 는 M8 존재 로직(runDbSeedCommand)을 그대로 위임한다 — domain/seed.ts
|
|
9
|
+
// 가 없으면 건너뛴다(파일 없음은 정상 케이스).
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { sql } from 'kysely';
|
|
13
|
+
import { snapshotFromDb, MIGRATIONS_TABLE } from '@gaonjs/data';
|
|
14
|
+
import { resolveDbTarget } from './resolve.js';
|
|
15
|
+
import { runDbMigrate } from './migrate.js';
|
|
16
|
+
import { runDbSeedCommand } from '../db.js';
|
|
17
|
+
/**
|
|
18
|
+
* `gaon db reset` — 위험 명령. NODE_ENV=production 은 무조건 거부.
|
|
19
|
+
* --yes 로 실 파괴 확증. --dry-run 은 무엇을 지울지·만들지만 출력.
|
|
20
|
+
*/
|
|
21
|
+
export async function runDbReset(opts) {
|
|
22
|
+
// 1) production fail-closed — 옵션·플래그 무관하게 거부.
|
|
23
|
+
if (process.env.NODE_ENV === 'production') {
|
|
24
|
+
const msg = ` ✗ [gaon db reset] NODE_ENV=production 에서 실행할 수 없습니다.\n` +
|
|
25
|
+
` → 실 운영 데이터 파괴 위험 · 개발/테스트 환경에서만 사용하세요.\n` +
|
|
26
|
+
` → 정말 필요하면 별도 절차(백업 + 수동 SQL)로 진행하고, 이 명령은\n` +
|
|
27
|
+
` NODE_ENV 를 비운(또는 development/test) 셸에서만 씁니다.`;
|
|
28
|
+
return {
|
|
29
|
+
exitCode: 1,
|
|
30
|
+
text: msg,
|
|
31
|
+
json: {
|
|
32
|
+
command: 'reset',
|
|
33
|
+
db: opts.dbKey,
|
|
34
|
+
ok: false,
|
|
35
|
+
reason: 'production-fail-closed',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// 2) --yes 게이트 — 사용자 확증. 프롬프트는 CLI 진입점에서 다루고,
|
|
40
|
+
// 이 함수는 순수 로직으로만 두어 --json 자동화와 정합.
|
|
41
|
+
if (!opts.yes) {
|
|
42
|
+
const msg = ` ✗ [gaon db reset] --yes 없이 실행할 수 없습니다 (실 데이터 파괴 확증 필요).\n` +
|
|
43
|
+
` → 확인 후 다음처럼 실행하세요:\n` +
|
|
44
|
+
` gaon db reset --yes --db ${opts.dbKey}\n` +
|
|
45
|
+
` → 무엇을 지울지·만들지만 보려면: gaon db reset --dry-run --db ${opts.dbKey}`;
|
|
46
|
+
// --dry-run 만 있고 --yes 없는 경우, dry-run 은 진행한다(파괴하지 않음).
|
|
47
|
+
if (!opts.dryRun) {
|
|
48
|
+
return {
|
|
49
|
+
exitCode: 1,
|
|
50
|
+
text: msg,
|
|
51
|
+
json: {
|
|
52
|
+
command: 'reset',
|
|
53
|
+
db: opts.dbKey,
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: 'yes-required',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const target = await resolveDbTarget({
|
|
61
|
+
cwd: opts.cwd,
|
|
62
|
+
dbKey: opts.dbKey,
|
|
63
|
+
configPath: opts.configPath,
|
|
64
|
+
});
|
|
65
|
+
// seed 는 자체적으로 env GAON_DATABASE_URL 을 읽는다(M8 관례). config 에서
|
|
66
|
+
// 얻은 URL 이 있으면 그대로 넘겨 env 미설정 프로젝트도 reset → seed 가 돈다.
|
|
67
|
+
const resolvedUrl = target.url;
|
|
68
|
+
const droppedTables = [];
|
|
69
|
+
let dryRunResult;
|
|
70
|
+
try {
|
|
71
|
+
// 현재 DB 스냅샷 — 지울 테이블 목록. dialect.introspect 는 이미
|
|
72
|
+
// _gaon_migrations 를 제외하지만, reset 은 이력도 지워야 하므로 별도 처리.
|
|
73
|
+
const snapshot = await snapshotFromDb(target.db, target.dialect);
|
|
74
|
+
const userTables = Object.keys(snapshot).sort();
|
|
75
|
+
// dry-run: 무엇을 지울지·만들지를 계산해 출력. 실제 파괴는 안 함.
|
|
76
|
+
if (opts.dryRun) {
|
|
77
|
+
const dropSqls = userTables.map((t) => target.dialect.dropTable(t));
|
|
78
|
+
dryRunResult = {
|
|
79
|
+
exitCode: 0,
|
|
80
|
+
text: ` [${opts.dbKey}] --dry-run · 리셋 후 상태를 미리 봅니다.\n` +
|
|
81
|
+
` -- drop (${userTables.length}) --\n` +
|
|
82
|
+
dropSqls.map((s) => ` ${s}`).join('\n') +
|
|
83
|
+
(userTables.length ? '\n' : '') +
|
|
84
|
+
` drop table if exists ${MIGRATIONS_TABLE}\n` +
|
|
85
|
+
` → 이후 'gaon db migrate' 로 스키마 ${target.tables.length}개 테이블이 재생성됩니다.`,
|
|
86
|
+
json: {
|
|
87
|
+
command: 'reset',
|
|
88
|
+
db: opts.dbKey,
|
|
89
|
+
dryRun: true,
|
|
90
|
+
wouldDrop: [...userTables, MIGRATIONS_TABLE],
|
|
91
|
+
wouldMigrateTables: target.tables.map((t) => t.name),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
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);
|
|
102
|
+
}
|
|
103
|
+
// 이력 테이블 삭제 — 존재하지 않아도 에러 없게.
|
|
104
|
+
await sql.raw(`drop table if exists ${MIGRATIONS_TABLE}`).execute(target.db);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
// dryRun·성공·실패 모든 경로에서 커넥션 정리.
|
|
109
|
+
await target.close();
|
|
110
|
+
}
|
|
111
|
+
if (dryRunResult)
|
|
112
|
+
return dryRunResult;
|
|
113
|
+
// 재마이그 — runDbMigrate 로 위임(이력 기록도 그대로 남는다).
|
|
114
|
+
const migrated = await runDbMigrate({
|
|
115
|
+
cwd: opts.cwd,
|
|
116
|
+
dbKey: opts.dbKey,
|
|
117
|
+
json: false, // 내부 호출 · 결과는 우리가 합쳐서 낸다
|
|
118
|
+
dryRun: false,
|
|
119
|
+
configPath: opts.configPath,
|
|
120
|
+
});
|
|
121
|
+
// seed — domain/seed.ts 가 있으면 실행. 없으면 조용히 건너뛴다.
|
|
122
|
+
const seedPath = join(opts.cwd, 'domain', 'seed.ts');
|
|
123
|
+
let seeded = false;
|
|
124
|
+
let seedText = '';
|
|
125
|
+
if (existsSync(seedPath)) {
|
|
126
|
+
const seedRes = await runDbSeedCommand({
|
|
127
|
+
root: opts.cwd,
|
|
128
|
+
json: false,
|
|
129
|
+
databaseUrl: resolvedUrl,
|
|
130
|
+
});
|
|
131
|
+
seeded = seedRes.exitCode === 0;
|
|
132
|
+
seedText = seedRes.text;
|
|
133
|
+
}
|
|
134
|
+
const migrateJson = migrated.json;
|
|
135
|
+
const text = ` [${opts.dbKey}] reset · ${droppedTables.length}개 테이블 삭제 + _gaon_migrations 초기화 → ` +
|
|
136
|
+
`${migrateJson.applied ?? 0}개 문 재적용` +
|
|
137
|
+
(seeded ? `\n${seedText}` : seedText ? `\n${seedText}` : '');
|
|
138
|
+
return {
|
|
139
|
+
exitCode: 0,
|
|
140
|
+
text,
|
|
141
|
+
json: {
|
|
142
|
+
command: 'reset',
|
|
143
|
+
db: opts.dbKey,
|
|
144
|
+
ok: true,
|
|
145
|
+
dropped: droppedTables,
|
|
146
|
+
applied: migrateJson.applied ?? 0,
|
|
147
|
+
seeded,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type TableDef } from '@gaonjs/data';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import { type Dialect, type AdapterName } from '@gaonjs/data';
|
|
4
|
+
export interface ResolveDbOptions {
|
|
5
|
+
readonly cwd: string;
|
|
6
|
+
readonly dbKey: string;
|
|
7
|
+
/** --config <path> 로 사용자가 지정한 config 경로. 없으면 cwd 관례. */
|
|
8
|
+
readonly configPath?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ResolvedDbTarget {
|
|
11
|
+
readonly dbKey: string;
|
|
12
|
+
readonly adapter: AdapterName;
|
|
13
|
+
readonly db: Kysely<any>;
|
|
14
|
+
readonly dialect: Dialect;
|
|
15
|
+
/** 이 커넥션에 바인딩된 스키마 테이블 정의 목록. */
|
|
16
|
+
readonly tables: TableDef[];
|
|
17
|
+
/** 실제 로드된 config 경로 (사람 출력용). 없으면 undefined. */
|
|
18
|
+
readonly configPath?: string;
|
|
19
|
+
/** 커넥션 URL — 다른 서브커맨드(seed)에 URL 전달용. url 없는 config 는 undefined. */
|
|
20
|
+
readonly url?: string;
|
|
21
|
+
/** 커넥션 정리 — CLI 종료 전 항상 호출. */
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* gaon.config.ts 를 로드해 target db 커넥션을 등록하고 스키마 테이블을 모아 온다.
|
|
26
|
+
*
|
|
27
|
+
* 커넥션 결정 순서:
|
|
28
|
+
* 1) config.db[dbKey] 가 있으면 그 어댑터·URL 사용
|
|
29
|
+
* 2) dbKey='main' 이고 GAON_DATABASE_URL 이 있으면 그 URL 로 main 을 세움
|
|
30
|
+
* 3) 그 외 → 수리 안내 에러
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveDbTarget(opts: ResolveDbOptions): Promise<ResolvedDbTarget>;
|