@gaonjs/cli 0.5.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/check.d.ts +21 -2
- package/dist/commands/check.js +70 -7
- package/dist/commands/db.d.ts +3 -1
- package/dist/commands/db.js +8 -2
- package/dist/commands/g.d.ts +1 -1
- package/dist/commands/g.js +27 -3
- package/dist/commands/mcp.d.ts +15 -0
- package/dist/commands/mcp.js +78 -0
- package/dist/db/diff.js +5 -0
- package/dist/db/journal.d.ts +34 -0
- package/dist/db/journal.js +71 -0
- package/dist/db/migrate.d.ts +6 -1
- package/dist/db/migrate.js +120 -102
- package/dist/db/replay.d.ts +49 -0
- package/dist/db/replay.js +148 -0
- package/dist/db/status.d.ts +12 -0
- package/dist/db/status.js +61 -0
- package/dist/dev/index.d.ts +2 -0
- package/dist/dev/index.js +2 -0
- package/dist/dev/vite.d.ts +67 -0
- package/dist/dev/vite.js +126 -0
- package/dist/dev.d.ts +18 -0
- package/dist/dev.js +15 -0
- package/dist/doctor/agents-doc-index.d.ts +4 -0
- package/dist/doctor/agents-doc-index.js +80 -0
- package/dist/doctor/fixers/dependency-direction.d.ts +9 -0
- package/dist/doctor/fixers/dependency-direction.js +98 -0
- package/dist/doctor/fixers/index.d.ts +15 -0
- package/dist/doctor/fixers/index.js +66 -0
- package/dist/doctor/fixers/schema-filename.d.ts +14 -0
- package/dist/doctor/fixers/schema-filename.js +104 -0
- package/dist/doctor/fixers/types.d.ts +59 -0
- package/dist/doctor/fixers/types.js +15 -0
- package/dist/doctor/schema-filename.d.ts +6 -0
- package/dist/doctor/schema-filename.js +81 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +49 -0
- package/dist/doctor.js +179 -5
- package/dist/generate.js +2 -2
- package/dist/hub.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +50 -10
- package/dist/mcp/index.d.ts +7 -0
- package/dist/mcp/index.js +7 -0
- package/dist/mcp/server.d.ts +50 -0
- package/dist/mcp/server.js +102 -0
- package/dist/mcp/tools.d.ts +109 -0
- package/dist/mcp/tools.js +485 -0
- package/dist/scaffold/app.d.ts +5 -0
- package/dist/scaffold/app.js +172 -0
- package/dist/scaffold/controller.js +2 -2
- package/dist/scaffold/index.d.ts +2 -1
- package/dist/scaffold/index.js +2 -1
- package/dist/scaffold/job.d.ts +5 -0
- package/dist/scaffold/job.js +35 -0
- package/dist/scaffold/model.js +8 -8
- package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
- package/dist/templates/auth/registration.controller.ts.tpl +1 -1
- package/dist/templates/auth/session.controller.ts.tpl +1 -1
- package/dist/templates/auth/user.model.ts.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +210 -0
- package/dist/templates/project/agents/async.md.tpl +218 -0
- package/dist/templates/project/agents/data.md.tpl +532 -0
- package/dist/templates/project/agents/frontend.md.tpl +201 -0
- package/dist/templates/project/agents/realtime.md.tpl +157 -0
- package/dist/templates/project/agents/security.md.tpl +92 -0
- package/dist/templates/project/agents/testing.md.tpl +101 -0
- package/dist/templates/project/agents/web.md.tpl +177 -0
- package/dist/templates/project/apps/web/index.html.tpl +18 -0
- package/dist/templates/project/apps/web/main.ts.tpl +24 -0
- package/dist/templates/project/package.json.tpl +5 -2
- package/dist/templates/project/vite.config.ts.tpl +23 -0
- package/dist/tsResolve.js +1 -1
- package/dist/work.d.ts +2 -2
- package/dist/work.js +3 -1
- package/package.json +13 -11
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.d.ts +0 -12
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.js +0 -7
- package/dist/__fixtures__/db-minimal/gaon.config.d.ts +0 -2
- package/dist/__fixtures__/db-minimal/gaon.config.js +0 -11
- package/dist/check.d.ts +0 -29
- package/dist/check.js +0 -92
package/dist/db/migrate.js
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
// @gaonjs/cli · `gaon db migrate` —
|
|
1
|
+
// @gaonjs/cli · `gaon db migrate` — 합성형 마이그레이션 (결정 39 · v0.16 §4.8)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// 1) resolveDbTarget →
|
|
5
|
-
// 2)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
3
|
+
// 흐름(합성형):
|
|
4
|
+
// 1) resolveDbTarget → 커넥션·스키마·방언 확보.
|
|
5
|
+
// 2) replay 단계 — 아직 적용 안 된 db/migrations/*.ts 를 파일명 순으로
|
|
6
|
+
// 실행하고 이력(kind='file')에 남긴다(손작성·백필 등 1급 실행 · §4.8).
|
|
7
|
+
// 3) schema-diff 단계 — computeMigration 으로 스키마↔DB 차이를 계산해
|
|
8
|
+
// 나머지를 적용하고 이력(kind='schema')에 남긴다. replay 가 만든 테이블은
|
|
9
|
+
// introspection 에 잡혀 다시 만들지 않는다(두 단계가 충돌 없이 합쳐짐).
|
|
10
|
+
// 4) `gaon db migrate down` — 가장 최근 이력 한 건을 되돌린다(rollbackLast).
|
|
11
|
+
// 5) --dry-run 이면 replay 예정 파일 + schema up SQL 만 출력(실행 X).
|
|
12
12
|
//
|
|
13
|
-
// 트랜잭션: postgres 는 트랜잭셔널 DDL 이라
|
|
14
|
-
// autocommit 이라
|
|
15
|
-
// 삽입만 트랜잭션에 감싼다(에러 안내로 사용자에게 알린다).
|
|
13
|
+
// 트랜잭션: postgres 는 트랜잭셔널 DDL 이라 각 단계가 원자적. mysql/mariadb 는
|
|
14
|
+
// DDL 이 autocommit 이라 순차 실행하고 이력만 남긴다(§4.5 방언 차이).
|
|
16
15
|
import { sql } from 'kysely';
|
|
17
|
-
import { computeMigration,
|
|
16
|
+
import { computeMigration, renderUp, renderDown } from '@gaonjs/data';
|
|
18
17
|
import { resolveDbTarget } from './resolve.js';
|
|
18
|
+
import { ensureJournal, journalExists, recordEntry } from './journal.js';
|
|
19
|
+
import { listMigrationFiles, replayPending, rollbackLast } from './replay.js';
|
|
19
20
|
function opSummary(op) {
|
|
20
21
|
switch (op.kind) {
|
|
21
22
|
case 'createTable':
|
|
@@ -28,44 +29,12 @@ function opSummary(op) {
|
|
|
28
29
|
return { kind: op.kind, table: op.table, column: op.column };
|
|
29
30
|
}
|
|
30
31
|
}
|
|
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
32
|
/** 배치 식별자 — 초 단위 epoch + 적용 문수. 사람도 읽고 정렬도 된다. */
|
|
64
33
|
function batchId(count) {
|
|
65
34
|
return `${Date.now()}-${count}`;
|
|
66
35
|
}
|
|
67
36
|
/**
|
|
68
|
-
* `gaon db migrate` — 진짜 apply. 실 DB 필요(§9).
|
|
37
|
+
* `gaon db migrate` — 진짜 apply(합성형: replay → schema-diff). 실 DB 필요(§9).
|
|
69
38
|
*/
|
|
70
39
|
export async function runDbMigrate(opts) {
|
|
71
40
|
const target = await resolveDbTarget({
|
|
@@ -74,90 +43,107 @@ export async function runDbMigrate(opts) {
|
|
|
74
43
|
configPath: opts.configPath,
|
|
75
44
|
});
|
|
76
45
|
try {
|
|
77
|
-
|
|
46
|
+
if (opts.down)
|
|
47
|
+
return await runRollback(opts, target);
|
|
48
|
+
// 1) replay — db/migrations/*.ts 를 먼저 실행.
|
|
49
|
+
const replay = await replayPending({
|
|
50
|
+
cwd: opts.cwd,
|
|
51
|
+
db: target.db,
|
|
52
|
+
dbKey: opts.dbKey,
|
|
53
|
+
adapter: target.adapter,
|
|
54
|
+
dryRun: opts.dryRun,
|
|
55
|
+
});
|
|
56
|
+
// 2) schema-diff — replay 이후 상태를 다시 읽어 나머지를 계산.
|
|
57
|
+
// 합성형(결정 39): migrate 는 스키마에 없는 테이블을 **자동 DROP 하지 않는다**
|
|
58
|
+
// — replay(파일 마이그)가 만든 테이블·외부 테이블을 보호한다. 테이블 제거는
|
|
59
|
+
// 손작성 마이그(down 포함)로 한다. dropTable 은 'gaon db diff' 가 계속 보여준다.
|
|
60
|
+
const full = await computeMigration(target.db, target.tables, target.dialect);
|
|
61
|
+
const droppedTables = full.ops
|
|
62
|
+
.filter((o) => o.kind === 'dropTable')
|
|
63
|
+
.map((o) => o.name);
|
|
64
|
+
const schemaOps = full.ops.filter((o) => o.kind !== 'dropTable');
|
|
65
|
+
const plan = {
|
|
66
|
+
ops: schemaOps,
|
|
67
|
+
up: renderUp(schemaOps, target.dialect),
|
|
68
|
+
down: renderDown(schemaOps, target.dialect),
|
|
69
|
+
};
|
|
78
70
|
const ops = plan.ops.map(opSummary);
|
|
79
|
-
|
|
71
|
+
const dropNote = droppedTables.length > 0
|
|
72
|
+
? ` ℹ 스키마에 없는 테이블 ${droppedTables.length}개는 자동 DROP 하지 않았습니다: ${droppedTables.join(', ')}\n` +
|
|
73
|
+
` → 제거하려면 손작성 마이그(down 포함)를 쓰거나, 'gaon db diff' 로 계획을 확인하세요.`
|
|
74
|
+
: '';
|
|
75
|
+
const replayLine = replay.applied.length > 0
|
|
76
|
+
? ` [${opts.dbKey}] 마이그레이션 파일 ${replay.applied.length}개 실행: ${replay.applied.join(', ')}`
|
|
77
|
+
: replay.pending.length > 0 && opts.dryRun
|
|
78
|
+
? ` [${opts.dbKey}] 실행 예정 마이그레이션 파일 ${replay.pending.length}개: ${replay.pending.join(', ')}`
|
|
79
|
+
: '';
|
|
80
|
+
const baseJson = {
|
|
81
|
+
command: 'migrate',
|
|
82
|
+
db: opts.dbKey,
|
|
83
|
+
replayed: replay.applied,
|
|
84
|
+
pendingMigrations: replay.pending,
|
|
85
|
+
migrationFiles: replay.all,
|
|
86
|
+
skippedDropTables: droppedTables,
|
|
87
|
+
};
|
|
88
|
+
const prefixLines = (body) => (replayLine ? replayLine + '\n' : '') + (dropNote ? dropNote + '\n' : '') + body;
|
|
89
|
+
// schema 변경이 없다 — replay 만 있었을 수 있다. 성공으로 취급.
|
|
80
90
|
if (plan.up.length === 0) {
|
|
81
91
|
return {
|
|
82
92
|
exitCode: 0,
|
|
83
|
-
text: ` [${opts.dbKey}] 적용할
|
|
84
|
-
json: {
|
|
85
|
-
command: 'migrate',
|
|
86
|
-
db: opts.dbKey,
|
|
87
|
-
applied: 0,
|
|
88
|
-
ops: [],
|
|
89
|
-
dryRun: opts.dryRun,
|
|
90
|
-
},
|
|
93
|
+
text: prefixLines(` [${opts.dbKey}] 적용할 스키마 변경이 없습니다 — 스키마와 DB 가 일치합니다.`),
|
|
94
|
+
json: { ...baseJson, applied: 0, ops: [], dryRun: opts.dryRun },
|
|
91
95
|
};
|
|
92
96
|
}
|
|
93
|
-
// --dry-run:
|
|
97
|
+
// --dry-run: replay 예정 + schema up SQL 만 출력.
|
|
94
98
|
if (opts.dryRun) {
|
|
95
99
|
return {
|
|
96
100
|
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
|
-
},
|
|
101
|
+
text: prefixLines(` [${opts.dbKey}] --dry-run · 적용하지 않고 SQL 만 출력합니다 (${plan.up.length}문)\n` +
|
|
102
|
+
plan.up.map((s) => ` ${s}`).join('\n')),
|
|
103
|
+
json: { ...baseJson, applied: 0, ops, up: plan.up, dryRun: true },
|
|
107
104
|
};
|
|
108
105
|
}
|
|
109
106
|
// baseline 감지 — journal 이 없는데 createTable 이 포함 → 안내(§7.5.3).
|
|
110
|
-
// 에러가 아니라 정보성 노트로 처리(실행은 진행). 사용자가 기존 DB 를 갖고
|
|
111
|
-
// 있다면 diff 로 먼저 계획을 봐야 한다.
|
|
112
107
|
const hadJournal = await journalExists(target.db);
|
|
113
108
|
const hasCreate = plan.ops.some((o) => o.kind === 'createTable');
|
|
114
109
|
const baselineNote = !hadJournal && hasCreate
|
|
115
110
|
? ` ℹ 최초 마이그레이션(_gaon_migrations 미존재) — 이 배치가 baseline 이 됩니다.\n` +
|
|
116
|
-
` → 기존 DB 를 이어 쓰고 있었다면, 먼저 'gaon db diff' 로 계획을
|
|
117
|
-
` → 이미 존재하는 테이블은 스키마와 일치해야 합니다(불일치 시 alterColumn 이 함께 실행됩니다).`
|
|
111
|
+
` → 기존 DB 를 이어 쓰고 있었다면, 먼저 'gaon db diff' 로 계획을 확인하세요.`
|
|
118
112
|
: '';
|
|
119
|
-
// apply — up 을 순차 적용하고 이력 한
|
|
120
|
-
// ensureJournal 은 트랜잭션 밖에서(mysql 호환) 만들고, insert 만 트랜잭션 안.
|
|
113
|
+
// 3) schema-diff apply — up 을 순차 적용하고 이력 한 행(kind='schema')을 남긴다.
|
|
121
114
|
await ensureJournal(target.db);
|
|
122
115
|
const id = batchId(plan.up.length);
|
|
123
116
|
const summary = JSON.stringify(ops);
|
|
124
|
-
|
|
125
|
-
|
|
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 방언 차이).
|
|
117
|
+
const downSql = JSON.stringify(plan.down);
|
|
118
|
+
const applySchema = async (db) => {
|
|
142
119
|
for (const stmt of plan.up) {
|
|
143
120
|
if (stmt.trim().startsWith('--'))
|
|
144
121
|
continue;
|
|
145
|
-
await sql.raw(stmt).execute(
|
|
122
|
+
await sql.raw(stmt).execute(db);
|
|
146
123
|
}
|
|
147
|
-
await
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
124
|
+
await recordEntry(db, {
|
|
125
|
+
id,
|
|
126
|
+
kind: 'schema',
|
|
127
|
+
dbKey: opts.dbKey,
|
|
128
|
+
statements: plan.up.length,
|
|
129
|
+
summary,
|
|
130
|
+
downSql,
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
if (target.adapter === 'postgres') {
|
|
134
|
+
await target.db.transaction().execute(applySchema);
|
|
152
135
|
}
|
|
153
|
-
|
|
154
|
-
|
|
136
|
+
else {
|
|
137
|
+
// mysql/mariadb: DDL autocommit — 순차 실행 후 이력만 남긴다(§4.5).
|
|
138
|
+
await applySchema(target.db);
|
|
139
|
+
}
|
|
140
|
+
const text = prefixLines((baselineNote ? baselineNote + '\n' : '') +
|
|
141
|
+
` [${opts.dbKey}] ${plan.up.length}개 문 적용 완료 (batch ${id}).`);
|
|
155
142
|
return {
|
|
156
143
|
exitCode: 0,
|
|
157
144
|
text,
|
|
158
145
|
json: {
|
|
159
|
-
|
|
160
|
-
db: opts.dbKey,
|
|
146
|
+
...baseJson,
|
|
161
147
|
applied: plan.up.length,
|
|
162
148
|
ops,
|
|
163
149
|
up: plan.up,
|
|
@@ -171,3 +157,35 @@ export async function runDbMigrate(opts) {
|
|
|
171
157
|
await target.close();
|
|
172
158
|
}
|
|
173
159
|
}
|
|
160
|
+
/** `gaon db migrate down` — 가장 최근 이력 한 건을 되돌린다. */
|
|
161
|
+
async function runRollback(opts, target) {
|
|
162
|
+
const r = await rollbackLast({
|
|
163
|
+
cwd: opts.cwd,
|
|
164
|
+
db: target.db,
|
|
165
|
+
adapter: target.adapter,
|
|
166
|
+
dryRun: opts.dryRun,
|
|
167
|
+
});
|
|
168
|
+
const baseJson = { command: 'migrate', db: opts.dbKey, down: true, dryRun: opts.dryRun };
|
|
169
|
+
if (r.rolledBack === null) {
|
|
170
|
+
return {
|
|
171
|
+
exitCode: 0,
|
|
172
|
+
text: ` [${opts.dbKey}] 되돌릴 마이그레이션 이력이 없습니다.`,
|
|
173
|
+
json: { ...baseJson, rolledBack: null, kind: null },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (opts.dryRun) {
|
|
177
|
+
return {
|
|
178
|
+
exitCode: 0,
|
|
179
|
+
text: ` [${opts.dbKey}] --dry-run · 되돌릴 대상: ${r.rolledBack} (${r.kind}) — 실행하지 않았습니다.`,
|
|
180
|
+
json: { ...baseJson, rolledBack: r.rolledBack, kind: r.kind },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const what = r.kind === 'file' ? `마이그레이션 파일 ${r.rolledBack}` : `스키마 배치 ${r.rolledBack}`;
|
|
184
|
+
return {
|
|
185
|
+
exitCode: 0,
|
|
186
|
+
text: ` [${opts.dbKey}] 롤백 완료 — ${what} 을(를) 되돌렸습니다.`,
|
|
187
|
+
json: { ...baseJson, rolledBack: r.rolledBack, kind: r.kind },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** `gaon db status` 가 재사용하는 마이그레이션 파일 목록(파일명 순). */
|
|
191
|
+
export { listMigrationFiles };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Kysely } from 'kysely';
|
|
2
|
+
import type { AdapterName } from '@gaonjs/data';
|
|
3
|
+
import { type MigrationKind } from './journal.js';
|
|
4
|
+
export interface MigrationModule {
|
|
5
|
+
up(db: Kysely<any>): Promise<void>;
|
|
6
|
+
down?(db: Kysely<any>): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/** db/migrations/ 아래 리플레이 대상 파일(파일명 오름차순). */
|
|
9
|
+
export declare function listMigrationFiles(cwd: string): string[];
|
|
10
|
+
/** db/migrations/<file> 을 동적 import 한다(tsResolve 훅 전제 — resolveDbTarget 이 등록). */
|
|
11
|
+
export declare function loadMigration(cwd: string, file: string): Promise<MigrationModule>;
|
|
12
|
+
export interface ReplayOptions {
|
|
13
|
+
readonly cwd: string;
|
|
14
|
+
readonly db: Kysely<any>;
|
|
15
|
+
readonly dbKey: string;
|
|
16
|
+
readonly adapter: AdapterName;
|
|
17
|
+
readonly dryRun: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface ReplayResult {
|
|
20
|
+
/** 이번에 실행된 파일명(파일명 순). */
|
|
21
|
+
readonly applied: string[];
|
|
22
|
+
/** (dry-run 또는 실행 전) 실행 예정 파일명. */
|
|
23
|
+
readonly pending: string[];
|
|
24
|
+
/** 전체 마이그레이션 파일 목록. */
|
|
25
|
+
readonly all: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 아직 적용되지 않은 db/migrations/*.ts 를 파일명 순으로 실행한다.
|
|
29
|
+
* postgres 는 파일 up() + 이력 기록을 한 트랜잭션에 넣어 원자적이다.
|
|
30
|
+
* mysql/mariadb 는 DDL 이 autocommit 이라 파일 단위로 정직하게 순차 실행한다.
|
|
31
|
+
*/
|
|
32
|
+
export declare function replayPending(opts: ReplayOptions): Promise<ReplayResult>;
|
|
33
|
+
export interface RollbackResult {
|
|
34
|
+
/** 되돌린 이력 id(파일명 또는 schema 배치 id). 되돌릴 게 없으면 null. */
|
|
35
|
+
readonly rolledBack: string | null;
|
|
36
|
+
readonly kind: MigrationKind | null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 가장 최근 적용된 이력 한 건을 되돌린다(`gaon db migrate down`).
|
|
40
|
+
* · kind='file' → 파일 down() 호출(없으면 §7.5.3 안내 에러).
|
|
41
|
+
* · kind='schema' → 저장된 down SQL 실행.
|
|
42
|
+
* 되돌린 뒤 이력 행을 지운다. --dry-run 은 대상만 보고 실행하지 않는다.
|
|
43
|
+
*/
|
|
44
|
+
export declare function rollbackLast(opts: {
|
|
45
|
+
readonly cwd: string;
|
|
46
|
+
readonly db: Kysely<any>;
|
|
47
|
+
readonly adapter: AdapterName;
|
|
48
|
+
readonly dryRun: boolean;
|
|
49
|
+
}): Promise<RollbackResult>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @gaonjs/cli · db/migrations 파일 리플레이 엔진 (결정 39 · v0.16 §4.8)
|
|
2
|
+
//
|
|
3
|
+
// 손작성(또는 생성된) db/migrations/*.ts 를 파일명 순으로 실행한다. 각 파일은
|
|
4
|
+
// export async function up(db: Kysely<any>): Promise<void>
|
|
5
|
+
// export async function down(db: Kysely<any>): Promise<void> // 롤백용(선택)
|
|
6
|
+
// 을 내보낸다. `gaon db migrate` 는 이 리플레이를 **먼저** 돌리고, 그 뒤
|
|
7
|
+
// schema-diff 로 나머지를 반영한다(합성형 · 결정 39). schema-diff 는 실 DB
|
|
8
|
+
// introspection 기반이라 리플레이가 만든 테이블을 다시 만들지 않는다 — 두
|
|
9
|
+
// 단계가 충돌 없이 합쳐진다.
|
|
10
|
+
//
|
|
11
|
+
// 이력은 _gaon_migrations 에 파일별 한 행(kind='file', id=파일명)으로 남겨
|
|
12
|
+
// 재실행 시 건너뛴다(중복 방지). 파일명이 곧 버전 키다 — `0001_*` 시퀀스든
|
|
13
|
+
// `20260724_*` 타임스탬프든 사전순 정렬이 안정적이다(§4.8 파일명 규약).
|
|
14
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
17
|
+
import { sql } from 'kysely';
|
|
18
|
+
import { appliedFileMigrations, deleteEntry, ensureJournal, latestEntry, recordEntry, } from './journal.js';
|
|
19
|
+
/** db/migrations/ 아래 리플레이 대상 파일(파일명 오름차순). */
|
|
20
|
+
export function listMigrationFiles(cwd) {
|
|
21
|
+
const dir = join(cwd, 'db', 'migrations');
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = readdirSync(dir);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return []; // db/migrations/ 없음 = 파일 없음
|
|
28
|
+
}
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const name of entries) {
|
|
31
|
+
// 리플레이는 up()/down() 함수를 부르므로 실행 가능한 모듈만 대상.
|
|
32
|
+
if (!/\.(ts|js|mjs|cjs)$/.test(name))
|
|
33
|
+
continue;
|
|
34
|
+
if (name.endsWith('.d.ts'))
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
if (!statSync(join(dir, name)).isFile())
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
out.push(name);
|
|
44
|
+
}
|
|
45
|
+
return out.sort();
|
|
46
|
+
}
|
|
47
|
+
/** db/migrations/<file> 을 동적 import 한다(tsResolve 훅 전제 — resolveDbTarget 이 등록). */
|
|
48
|
+
export async function loadMigration(cwd, file) {
|
|
49
|
+
const abs = join(cwd, 'db', 'migrations', file);
|
|
50
|
+
const mod = (await import(pathToFileURL(abs).href));
|
|
51
|
+
if (typeof mod.up !== 'function') {
|
|
52
|
+
throw new Error(`[gaon db] 마이그레이션 파일에 up() export 가 없습니다: db/migrations/${file}\n` +
|
|
53
|
+
` → 다음 형태로 두세요:\n` +
|
|
54
|
+
` import type { Kysely } from 'kysely'\n` +
|
|
55
|
+
` export async function up(db: Kysely<any>): Promise<void> { /* ... */ }\n` +
|
|
56
|
+
` export async function down(db: Kysely<any>): Promise<void> { /* ... */ }`);
|
|
57
|
+
}
|
|
58
|
+
return mod;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 아직 적용되지 않은 db/migrations/*.ts 를 파일명 순으로 실행한다.
|
|
62
|
+
* postgres 는 파일 up() + 이력 기록을 한 트랜잭션에 넣어 원자적이다.
|
|
63
|
+
* mysql/mariadb 는 DDL 이 autocommit 이라 파일 단위로 정직하게 순차 실행한다.
|
|
64
|
+
*/
|
|
65
|
+
export async function replayPending(opts) {
|
|
66
|
+
const all = listMigrationFiles(opts.cwd);
|
|
67
|
+
if (all.length === 0)
|
|
68
|
+
return { applied: [], pending: [], all: [] };
|
|
69
|
+
const done = await appliedFileMigrations(opts.db);
|
|
70
|
+
const pending = all.filter((f) => !done.has(f));
|
|
71
|
+
if (opts.dryRun || pending.length === 0) {
|
|
72
|
+
return { applied: [], pending, all };
|
|
73
|
+
}
|
|
74
|
+
await ensureJournal(opts.db);
|
|
75
|
+
const applied = [];
|
|
76
|
+
for (const file of pending) {
|
|
77
|
+
const mig = await loadMigration(opts.cwd, file);
|
|
78
|
+
const record = (db) => recordEntry(db, {
|
|
79
|
+
id: file,
|
|
80
|
+
kind: 'file',
|
|
81
|
+
dbKey: opts.dbKey,
|
|
82
|
+
statements: 0, // 임의 up() 이라 문 수를 세지 않는다.
|
|
83
|
+
summary: JSON.stringify({ file, kind: 'file' }),
|
|
84
|
+
downSql: null,
|
|
85
|
+
});
|
|
86
|
+
if (opts.adapter === 'postgres') {
|
|
87
|
+
await opts.db.transaction().execute(async (trx) => {
|
|
88
|
+
await mig.up(trx);
|
|
89
|
+
await record(trx);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
await mig.up(opts.db);
|
|
94
|
+
await record(opts.db);
|
|
95
|
+
}
|
|
96
|
+
applied.push(file);
|
|
97
|
+
}
|
|
98
|
+
return { applied, pending: [], all };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 가장 최근 적용된 이력 한 건을 되돌린다(`gaon db migrate down`).
|
|
102
|
+
* · kind='file' → 파일 down() 호출(없으면 §7.5.3 안내 에러).
|
|
103
|
+
* · kind='schema' → 저장된 down SQL 실행.
|
|
104
|
+
* 되돌린 뒤 이력 행을 지운다. --dry-run 은 대상만 보고 실행하지 않는다.
|
|
105
|
+
*/
|
|
106
|
+
export async function rollbackLast(opts) {
|
|
107
|
+
const entry = await latestEntry(opts.db);
|
|
108
|
+
if (!entry)
|
|
109
|
+
return { rolledBack: null, kind: null };
|
|
110
|
+
if (opts.dryRun)
|
|
111
|
+
return { rolledBack: entry.id, kind: entry.kind };
|
|
112
|
+
if (entry.kind === 'file') {
|
|
113
|
+
const mig = await loadMigration(opts.cwd, entry.id);
|
|
114
|
+
if (typeof mig.down !== 'function') {
|
|
115
|
+
throw new Error(`[gaon db] 롤백할 down() 이 없습니다: db/migrations/${entry.id}\n` +
|
|
116
|
+
` → 이 파일에 export async function down(db: Kysely<any>) {…} 을 추가하거나,\n` +
|
|
117
|
+
` 수동으로 되돌린 뒤 _gaon_migrations 에서 이 행을 지우세요.`);
|
|
118
|
+
}
|
|
119
|
+
const down = mig.down;
|
|
120
|
+
if (opts.adapter === 'postgres') {
|
|
121
|
+
await opts.db.transaction().execute(async (trx) => {
|
|
122
|
+
await down(trx);
|
|
123
|
+
await deleteEntry(trx, entry.id);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
await down(opts.db);
|
|
128
|
+
await deleteEntry(opts.db, entry.id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const stmts = entry.down_sql ? JSON.parse(entry.down_sql) : [];
|
|
133
|
+
const run = async (db) => {
|
|
134
|
+
for (const s of stmts) {
|
|
135
|
+
if (!s.trim().startsWith('--'))
|
|
136
|
+
await sql.raw(s).execute(db);
|
|
137
|
+
}
|
|
138
|
+
await deleteEntry(db, entry.id);
|
|
139
|
+
};
|
|
140
|
+
if (opts.adapter === 'postgres') {
|
|
141
|
+
await opts.db.transaction().execute(run);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
await run(opts.db);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { rolledBack: entry.id, kind: entry.kind };
|
|
148
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface DbStatusOptions {
|
|
2
|
+
readonly cwd: string;
|
|
3
|
+
readonly dbKey: string;
|
|
4
|
+
readonly json: boolean;
|
|
5
|
+
readonly configPath?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DbStatusResult {
|
|
8
|
+
readonly exitCode: number;
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly json: unknown;
|
|
11
|
+
}
|
|
12
|
+
export declare function runDbStatus(opts: DbStatusOptions): Promise<DbStatusResult>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @gaonjs/cli · `gaon db status` — 마이그레이션 적용 현황 (결정 39 · v0.16 §4.8)
|
|
2
|
+
//
|
|
3
|
+
// db/migrations/*.ts 각각의 적용/미적용과, 스키마↔DB 사이에 아직 반영 안 된
|
|
4
|
+
// 변경(schema drift) 수를 함께 보여준다. 아무것도 실행하지 않는다.
|
|
5
|
+
import { computeMigration } from '@gaonjs/data';
|
|
6
|
+
import { resolveDbTarget } from './resolve.js';
|
|
7
|
+
import { appliedFileMigrations } from './journal.js';
|
|
8
|
+
import { listMigrationFiles } from './replay.js';
|
|
9
|
+
export async function runDbStatus(opts) {
|
|
10
|
+
const target = await resolveDbTarget({
|
|
11
|
+
cwd: opts.cwd,
|
|
12
|
+
dbKey: opts.dbKey,
|
|
13
|
+
configPath: opts.configPath,
|
|
14
|
+
});
|
|
15
|
+
try {
|
|
16
|
+
const files = listMigrationFiles(opts.cwd);
|
|
17
|
+
const done = await appliedFileMigrations(target.db);
|
|
18
|
+
const migrations = files.map((f) => ({ file: `db/migrations/${f}`, applied: done.has(f) }));
|
|
19
|
+
const pendingFiles = migrations.filter((m) => !m.applied).length;
|
|
20
|
+
// migrate 는 스키마에 없는 테이블을 자동 DROP 하지 않으므로(결정 39 · §4.8)
|
|
21
|
+
// dropTable 은 drift 로 세지 않는다 — 별도로 참고 정보(extraTables)로만 보고.
|
|
22
|
+
const plan = await computeMigration(target.db, target.tables, target.dialect);
|
|
23
|
+
const extraTables = plan.ops
|
|
24
|
+
.filter((o) => o.kind === 'dropTable')
|
|
25
|
+
.map((o) => o.name);
|
|
26
|
+
const schemaChanges = plan.ops.length - extraTables.length;
|
|
27
|
+
const lines = [];
|
|
28
|
+
if (migrations.length === 0) {
|
|
29
|
+
lines.push(` [${opts.dbKey}] 마이그레이션 파일 없음 (db/migrations/).`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
lines.push(` [${opts.dbKey}] 마이그레이션 파일 ${migrations.length}개`);
|
|
33
|
+
for (const m of migrations) {
|
|
34
|
+
lines.push(` ${m.applied ? '✓ 적용' : '· 대기'} ${m.file}`);
|
|
35
|
+
}
|
|
36
|
+
if (pendingFiles > 0)
|
|
37
|
+
lines.push(` → 대기 ${pendingFiles}개 — 'gaon db migrate' 로 적용하세요.`);
|
|
38
|
+
}
|
|
39
|
+
lines.push(schemaChanges === 0
|
|
40
|
+
? ` 스키마 drift 없음 — 스키마와 DB 가 일치합니다.`
|
|
41
|
+
: ` 스키마 drift ${schemaChanges}개 — 'gaon db migrate' 로 반영하세요('gaon db diff' 로 미리보기).`);
|
|
42
|
+
if (extraTables.length > 0) {
|
|
43
|
+
lines.push(` 참고: 스키마에 없는 테이블 ${extraTables.length}개(자동 DROP 안 함): ${extraTables.join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
exitCode: 0,
|
|
47
|
+
text: lines.join('\n'),
|
|
48
|
+
json: {
|
|
49
|
+
command: 'status',
|
|
50
|
+
db: opts.dbKey,
|
|
51
|
+
migrations,
|
|
52
|
+
pendingFiles,
|
|
53
|
+
schemaChanges,
|
|
54
|
+
extraTables,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await target.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/dev/index.d.ts
CHANGED
|
@@ -12,3 +12,5 @@ export { startTscWatchers, killChild } from './tsc.js';
|
|
|
12
12
|
export type { TscWatcherOptions, TscWatcherHandle } from './tsc.js';
|
|
13
13
|
export { startRestartWatcher, isRestartChange, resolveWatchRoots } from './watcher.js';
|
|
14
14
|
export type { RestartWatcherOptions, RestartWatcherHandle } from './watcher.js';
|
|
15
|
+
export { createGaonViteServer } from './vite.js';
|
|
16
|
+
export type { GaonViteServer, CreateGaonViteServerOptions } from './vite.js';
|
package/dist/dev/index.js
CHANGED
|
@@ -8,3 +8,5 @@ export { createDevConsole } from './console.js';
|
|
|
8
8
|
export { findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, } from './docker.js';
|
|
9
9
|
export { startTscWatchers, killChild } from './tsc.js';
|
|
10
10
|
export { startRestartWatcher, isRestartChange, resolveWatchRoots } from './watcher.js';
|
|
11
|
+
// Vite dev server 프로그램적 기동 (M3-runtime) — dev.ts 배선은 M1 이 통합.
|
|
12
|
+
export { createGaonViteServer } from './vite.js';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gaonjs/cli · dev/vite — Vite dev server 프로그램적 기동 (M3-runtime)
|
|
3
|
+
*
|
|
4
|
+
* 정본 정합:
|
|
5
|
+
* · §6.4 Vue 어댑터 인터페이스 — `vite: () => [vuePlugin()]` 은 서버 배선
|
|
6
|
+
* 시 프론트엔드 어댑터가 제공한다. 이 파일은 그 배선을 CLI 측에서
|
|
7
|
+
* 쉽게 부를 수 있는 최소 래퍼일 뿐이다.
|
|
8
|
+
* · The One Way (§CLAUDE.md 6) — Vite 를 middlewareMode 로 띄운다. Fastify
|
|
9
|
+
* 한 포트로 API 와 프론트가 함께 서빙된다 — 두 포트·프록시 규칙 X.
|
|
10
|
+
*
|
|
11
|
+
* 이 파일은 `dev.ts` 에 배선되지 않는다(사용자 지침) — M1 세션이 통합
|
|
12
|
+
* 커밋에서 배선한다. 여기서는 함수만 노출한다.
|
|
13
|
+
*
|
|
14
|
+
* 참고: vite 는 이 파일을 실행하는 환경에 이미 설치돼 있어야 한다
|
|
15
|
+
* (packages/cli/package.json 에 dep 등록). vite 가 없으면 명확한 에러를
|
|
16
|
+
* 던져 사용자에게 설치 안내를 준다(§7.5.3 에러=수리 안내서).
|
|
17
|
+
*/
|
|
18
|
+
/** Vite dev server 핸들 — Fastify 에서 middlewares 로 소비하고, close() 로 정리. */
|
|
19
|
+
export interface GaonViteServer {
|
|
20
|
+
/**
|
|
21
|
+
* Vite 가 만든 connect 스타일 미들웨어 체인. Fastify 에서
|
|
22
|
+
* `app.register(fastifyMiddie).then(() => app.use(server.middlewares))` 로
|
|
23
|
+
* 태우거나, 원시 `app.server.on('request', server.middlewares)` 로 붙인다.
|
|
24
|
+
*/
|
|
25
|
+
readonly middlewares: (req: unknown, res: unknown, next: (err?: unknown) => void) => void;
|
|
26
|
+
/**
|
|
27
|
+
* data-page 를 담은 서버 HTML 을 Vite 가 변환한다 — HMR 클라이언트 스크립트
|
|
28
|
+
* 주입, `<script type="module" src="/apps/<앱>/main.ts">` 처리 등.
|
|
29
|
+
* Fastify inertia 응답에서 최초 문서 로드 시 이 함수를 통과시킨다.
|
|
30
|
+
*/
|
|
31
|
+
transformIndexHtml(url: string, html: string): Promise<string>;
|
|
32
|
+
/** dev-only URL 로 모듈 로딩(SSR 렌더링 · 스크립트 확장 · 진단용). */
|
|
33
|
+
ssrLoadModule<T = unknown>(id: string): Promise<T>;
|
|
34
|
+
/** graceful shutdown — Fastify close 훅에서 부른다. */
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
/** 진단·테스트용 — Vite 인스턴스가 봤을 최종 config 의 root. */
|
|
37
|
+
readonly root: string;
|
|
38
|
+
}
|
|
39
|
+
export interface CreateGaonViteServerOptions {
|
|
40
|
+
/** 프로젝트 루트(package.json 이 있는 곳). vite.config.ts 도 이 곳에서 찾는다. */
|
|
41
|
+
readonly cwd: string;
|
|
42
|
+
/**
|
|
43
|
+
* 프론트엔드 앱 루트 — 이 폴더의 `index.html` 이 Vite 의 진입 HTML이 된다.
|
|
44
|
+
* The One Way 관례: apps/web(기본 앱). 다른 앱은 사용자 설정으로 override.
|
|
45
|
+
*/
|
|
46
|
+
readonly appRoot?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Vite 를 켤지 여부. false 면 이 함수는 아무 것도 하지 않고 undefined 를
|
|
49
|
+
* 반환한다 — dev.ts 가 CLI 옵션에서 결정한다.
|
|
50
|
+
*/
|
|
51
|
+
readonly enabled?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* 서버 로그를 통합 콘솔로 흘리는 훅. dev/console.ts 의 log('vite', ...)
|
|
54
|
+
* 를 여기에 연결한다.
|
|
55
|
+
*/
|
|
56
|
+
readonly onLog?: (line: string, level: 'info' | 'warn' | 'error') => void;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Vite dev server 를 middleware 모드로 띄운다. Fastify 에 붙여 하나의
|
|
60
|
+
* 포트로 API + 프론트를 함께 서빙한다(The One Way).
|
|
61
|
+
*
|
|
62
|
+
* 파일이 존재하지 않는 경우(초기 프로젝트에 apps/web/index.html 이 없는
|
|
63
|
+
* 순간)에는 명확한 에러를 던진다 — dev.ts 가 이 예외를 잡아 사용자에게
|
|
64
|
+
* "→ apps/<앱>/index.html 을 만들거나 gaon new 로 새 프로젝트를 만드세요"
|
|
65
|
+
* 라고 알린다.
|
|
66
|
+
*/
|
|
67
|
+
export declare function createGaonViteServer(opts: CreateGaonViteServerOptions): Promise<GaonViteServer | undefined>;
|