@mandujs/core 0.3.2 → 0.3.3
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/README.ko.md +200 -200
- package/README.md +200 -200
- package/package.json +4 -2
- package/src/change/history.ts +145 -0
- package/src/change/index.ts +40 -0
- package/src/change/integrity.ts +81 -0
- package/src/change/snapshot.ts +233 -0
- package/src/change/transaction.ts +293 -0
- package/src/change/types.ts +102 -0
- package/src/error/classifier.ts +314 -0
- package/src/error/formatter.ts +237 -0
- package/src/error/index.ts +25 -0
- package/src/error/stack-analyzer.ts +295 -0
- package/src/error/types.ts +140 -0
- package/src/filling/context.ts +228 -219
- package/src/filling/filling.ts +256 -234
- package/src/filling/index.ts +7 -7
- package/src/generator/generate.ts +85 -3
- package/src/generator/index.ts +2 -2
- package/src/guard/auto-correct.ts +257 -203
- package/src/index.ts +2 -0
- package/src/report/index.ts +1 -1
- package/src/runtime/index.ts +3 -3
- package/src/runtime/router.ts +65 -65
- package/src/runtime/server.ts +189 -139
- package/src/runtime/ssr.ts +38 -38
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import type { Snapshot, RestoreResult } from "./types";
|
|
3
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
4
|
+
import type { SpecLock } from "../spec/lock";
|
|
5
|
+
|
|
6
|
+
const SPEC_DIR = "spec";
|
|
7
|
+
const MANIFEST_FILE = "routes.manifest.json";
|
|
8
|
+
const LOCK_FILE = "spec.lock.json";
|
|
9
|
+
const SLOTS_DIR = "slots";
|
|
10
|
+
const HISTORY_DIR = "history";
|
|
11
|
+
const SNAPSHOTS_DIR = "snapshots";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 스냅샷 ID 생성 (YYYYMMDD-HHmmss-xxx)
|
|
15
|
+
*/
|
|
16
|
+
function generateSnapshotId(): string {
|
|
17
|
+
const now = new Date();
|
|
18
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
19
|
+
const time = now.toISOString().slice(11, 19).replace(/:/g, "");
|
|
20
|
+
const random = Math.random().toString(36).slice(2, 5);
|
|
21
|
+
return `${date}-${time}-${random}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 스냅샷 저장 경로 반환
|
|
26
|
+
*/
|
|
27
|
+
function getSnapshotPath(rootDir: string, snapshotId: string): string {
|
|
28
|
+
return path.join(rootDir, SPEC_DIR, HISTORY_DIR, SNAPSHOTS_DIR, `${snapshotId}.snapshot.json`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Slot 파일들의 내용을 수집
|
|
33
|
+
*/
|
|
34
|
+
async function collectSlotContents(rootDir: string): Promise<Record<string, string>> {
|
|
35
|
+
const slotsDir = path.join(rootDir, SPEC_DIR, SLOTS_DIR);
|
|
36
|
+
const contents: Record<string, string> = {};
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const entries = await Array.fromAsync(
|
|
40
|
+
new Bun.Glob("**/*.ts").scan({
|
|
41
|
+
cwd: slotsDir,
|
|
42
|
+
onlyFiles: true,
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
await Promise.all(
|
|
47
|
+
entries.map(async (entry) => {
|
|
48
|
+
const filePath = path.join(slotsDir, entry);
|
|
49
|
+
const file = Bun.file(filePath);
|
|
50
|
+
if (await file.exists()) {
|
|
51
|
+
contents[entry] = await file.text();
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
// slots 디렉토리가 없는 경우 빈 객체 반환
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return contents;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 현재 spec 상태의 스냅샷 생성
|
|
64
|
+
*/
|
|
65
|
+
export async function createSnapshot(rootDir: string): Promise<Snapshot> {
|
|
66
|
+
const specDir = path.join(rootDir, SPEC_DIR);
|
|
67
|
+
const manifestPath = path.join(specDir, MANIFEST_FILE);
|
|
68
|
+
const lockPath = path.join(specDir, LOCK_FILE);
|
|
69
|
+
|
|
70
|
+
// Manifest 읽기 (필수)
|
|
71
|
+
const manifestFile = Bun.file(manifestPath);
|
|
72
|
+
if (!(await manifestFile.exists())) {
|
|
73
|
+
throw new Error(`Manifest not found: ${manifestPath}`);
|
|
74
|
+
}
|
|
75
|
+
const manifest: RoutesManifest = await manifestFile.json();
|
|
76
|
+
|
|
77
|
+
// Lock 읽기 (선택)
|
|
78
|
+
let lock: SpecLock | null = null;
|
|
79
|
+
const lockFile = Bun.file(lockPath);
|
|
80
|
+
if (await lockFile.exists()) {
|
|
81
|
+
lock = await lockFile.json();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Slot 내용 수집
|
|
85
|
+
const slotContents = await collectSlotContents(rootDir);
|
|
86
|
+
|
|
87
|
+
const id = generateSnapshotId();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
id,
|
|
91
|
+
timestamp: new Date().toISOString(),
|
|
92
|
+
manifest,
|
|
93
|
+
lock,
|
|
94
|
+
slotContents,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 스냅샷 파일 읽기
|
|
100
|
+
*/
|
|
101
|
+
export async function readSnapshot(snapshotPath: string): Promise<Snapshot | null> {
|
|
102
|
+
try {
|
|
103
|
+
const file = Bun.file(snapshotPath);
|
|
104
|
+
if (!(await file.exists())) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return await file.json();
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 스냅샷 파일 저장
|
|
115
|
+
*/
|
|
116
|
+
export async function writeSnapshot(rootDir: string, snapshot: Snapshot): Promise<void> {
|
|
117
|
+
const snapshotPath = getSnapshotPath(rootDir, snapshot.id);
|
|
118
|
+
const snapshotDir = path.dirname(snapshotPath);
|
|
119
|
+
|
|
120
|
+
// 디렉토리 생성
|
|
121
|
+
await Bun.write(path.join(snapshotDir, ".gitkeep"), "");
|
|
122
|
+
|
|
123
|
+
// 스냅샷 저장
|
|
124
|
+
await Bun.write(snapshotPath, JSON.stringify(snapshot, null, 2));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 스냅샷 ID로 스냅샷 읽기
|
|
129
|
+
*/
|
|
130
|
+
export async function readSnapshotById(rootDir: string, snapshotId: string): Promise<Snapshot | null> {
|
|
131
|
+
const snapshotPath = getSnapshotPath(rootDir, snapshotId);
|
|
132
|
+
return readSnapshot(snapshotPath);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 스냅샷으로부터 상태 복원
|
|
137
|
+
*/
|
|
138
|
+
export async function restoreSnapshot(rootDir: string, snapshot: Snapshot): Promise<RestoreResult> {
|
|
139
|
+
const specDir = path.join(rootDir, SPEC_DIR);
|
|
140
|
+
const manifestPath = path.join(specDir, MANIFEST_FILE);
|
|
141
|
+
const lockPath = path.join(specDir, LOCK_FILE);
|
|
142
|
+
const slotsDir = path.join(specDir, SLOTS_DIR);
|
|
143
|
+
|
|
144
|
+
const restoredFiles: string[] = [];
|
|
145
|
+
const failedFiles: string[] = [];
|
|
146
|
+
const errors: string[] = [];
|
|
147
|
+
|
|
148
|
+
// 1. Manifest 복원
|
|
149
|
+
try {
|
|
150
|
+
await Bun.write(manifestPath, JSON.stringify(snapshot.manifest, null, 2));
|
|
151
|
+
restoredFiles.push(MANIFEST_FILE);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
failedFiles.push(MANIFEST_FILE);
|
|
154
|
+
errors.push(`Failed to restore manifest: ${error instanceof Error ? error.message : String(error)}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 2. Lock 복원 (있는 경우)
|
|
158
|
+
if (snapshot.lock) {
|
|
159
|
+
try {
|
|
160
|
+
await Bun.write(lockPath, JSON.stringify(snapshot.lock, null, 2));
|
|
161
|
+
restoredFiles.push(LOCK_FILE);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
failedFiles.push(LOCK_FILE);
|
|
164
|
+
errors.push(`Failed to restore lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 3. Slot 파일들 복원
|
|
169
|
+
for (const [relativePath, content] of Object.entries(snapshot.slotContents)) {
|
|
170
|
+
const filePath = path.join(slotsDir, relativePath);
|
|
171
|
+
try {
|
|
172
|
+
// 디렉토리 확보
|
|
173
|
+
const dir = path.dirname(filePath);
|
|
174
|
+
await Bun.write(path.join(dir, ".gitkeep"), "");
|
|
175
|
+
|
|
176
|
+
await Bun.write(filePath, content);
|
|
177
|
+
restoredFiles.push(`${SLOTS_DIR}/${relativePath}`);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
failedFiles.push(`${SLOTS_DIR}/${relativePath}`);
|
|
180
|
+
errors.push(
|
|
181
|
+
`Failed to restore slot ${relativePath}: ${error instanceof Error ? error.message : String(error)}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
success: failedFiles.length === 0,
|
|
188
|
+
restoredFiles,
|
|
189
|
+
failedFiles,
|
|
190
|
+
errors,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 스냅샷 삭제
|
|
196
|
+
*/
|
|
197
|
+
export async function deleteSnapshot(rootDir: string, snapshotId: string): Promise<boolean> {
|
|
198
|
+
const snapshotPath = getSnapshotPath(rootDir, snapshotId);
|
|
199
|
+
try {
|
|
200
|
+
const file = Bun.file(snapshotPath);
|
|
201
|
+
if (await file.exists()) {
|
|
202
|
+
const { unlink } = await import("fs/promises");
|
|
203
|
+
await unlink(snapshotPath);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 모든 스냅샷 ID 목록 조회
|
|
214
|
+
*/
|
|
215
|
+
export async function listSnapshotIds(rootDir: string): Promise<string[]> {
|
|
216
|
+
const snapshotsDir = path.join(rootDir, SPEC_DIR, HISTORY_DIR, SNAPSHOTS_DIR);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const entries = await Array.fromAsync(
|
|
220
|
+
new Bun.Glob("*.snapshot.json").scan({
|
|
221
|
+
cwd: snapshotsDir,
|
|
222
|
+
onlyFiles: true,
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return entries
|
|
227
|
+
.map((entry) => entry.replace(".snapshot.json", ""))
|
|
228
|
+
.sort()
|
|
229
|
+
.reverse(); // 최신 순
|
|
230
|
+
} catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import type {
|
|
3
|
+
ChangeRecord,
|
|
4
|
+
TransactionState,
|
|
5
|
+
BeginChangeOptions,
|
|
6
|
+
CommitResult,
|
|
7
|
+
RollbackResult,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { createSnapshot, writeSnapshot, readSnapshotById, restoreSnapshot } from "./snapshot";
|
|
10
|
+
|
|
11
|
+
const SPEC_DIR = "spec";
|
|
12
|
+
const HISTORY_DIR = "history";
|
|
13
|
+
const CHANGES_FILE = "changes.json";
|
|
14
|
+
const ACTIVE_FILE = "active.json";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 변경 ID 생성 (YYYYMMDD-HHmmss-xxx)
|
|
18
|
+
*/
|
|
19
|
+
function generateChangeId(): string {
|
|
20
|
+
const now = new Date();
|
|
21
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
22
|
+
const time = now.toISOString().slice(11, 19).replace(/:/g, "");
|
|
23
|
+
const random = Math.random().toString(36).slice(2, 5);
|
|
24
|
+
return `${date}-${time}-${random}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* History 디렉토리 경로
|
|
29
|
+
*/
|
|
30
|
+
function getHistoryDir(rootDir: string): string {
|
|
31
|
+
return path.join(rootDir, SPEC_DIR, HISTORY_DIR);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Changes 파일 경로
|
|
36
|
+
*/
|
|
37
|
+
function getChangesPath(rootDir: string): string {
|
|
38
|
+
return path.join(getHistoryDir(rootDir), CHANGES_FILE);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Active 파일 경로
|
|
43
|
+
*/
|
|
44
|
+
function getActivePath(rootDir: string): string {
|
|
45
|
+
return path.join(getHistoryDir(rootDir), ACTIVE_FILE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 모든 변경 기록 읽기
|
|
50
|
+
*/
|
|
51
|
+
async function readChanges(rootDir: string): Promise<ChangeRecord[]> {
|
|
52
|
+
const changesPath = getChangesPath(rootDir);
|
|
53
|
+
try {
|
|
54
|
+
const file = Bun.file(changesPath);
|
|
55
|
+
if (!(await file.exists())) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
return await file.json();
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 변경 기록 저장
|
|
66
|
+
*/
|
|
67
|
+
async function writeChanges(rootDir: string, changes: ChangeRecord[]): Promise<void> {
|
|
68
|
+
const changesPath = getChangesPath(rootDir);
|
|
69
|
+
const historyDir = getHistoryDir(rootDir);
|
|
70
|
+
|
|
71
|
+
// 디렉토리 확보
|
|
72
|
+
await Bun.write(path.join(historyDir, ".gitkeep"), "");
|
|
73
|
+
|
|
74
|
+
await Bun.write(changesPath, JSON.stringify(changes, null, 2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 현재 트랜잭션 상태 읽기
|
|
79
|
+
*/
|
|
80
|
+
async function readActiveState(rootDir: string): Promise<TransactionState> {
|
|
81
|
+
const activePath = getActivePath(rootDir);
|
|
82
|
+
try {
|
|
83
|
+
const file = Bun.file(activePath);
|
|
84
|
+
if (!(await file.exists())) {
|
|
85
|
+
return { active: false, changeId: null, snapshotId: null };
|
|
86
|
+
}
|
|
87
|
+
return await file.json();
|
|
88
|
+
} catch {
|
|
89
|
+
return { active: false, changeId: null, snapshotId: null };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 트랜잭션 상태 저장
|
|
95
|
+
*/
|
|
96
|
+
async function writeActiveState(rootDir: string, state: TransactionState): Promise<void> {
|
|
97
|
+
const activePath = getActivePath(rootDir);
|
|
98
|
+
const historyDir = getHistoryDir(rootDir);
|
|
99
|
+
|
|
100
|
+
// 디렉토리 확보
|
|
101
|
+
await Bun.write(path.join(historyDir, ".gitkeep"), "");
|
|
102
|
+
|
|
103
|
+
await Bun.write(activePath, JSON.stringify(state, null, 2));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 활성 트랜잭션 존재 여부 확인
|
|
108
|
+
*/
|
|
109
|
+
export async function hasActiveTransaction(rootDir: string): Promise<boolean> {
|
|
110
|
+
const state = await readActiveState(rootDir);
|
|
111
|
+
return state.active;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 현재 활성 트랜잭션 정보 조회
|
|
116
|
+
*/
|
|
117
|
+
export async function getActiveTransaction(rootDir: string): Promise<ChangeRecord | null> {
|
|
118
|
+
const state = await readActiveState(rootDir);
|
|
119
|
+
if (!state.active || !state.changeId) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const changes = await readChanges(rootDir);
|
|
124
|
+
return changes.find((c) => c.id === state.changeId) || null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 변경 트랜잭션 시작
|
|
129
|
+
* - 스냅샷 생성 후 활성 트랜잭션으로 등록
|
|
130
|
+
*/
|
|
131
|
+
export async function beginChange(
|
|
132
|
+
rootDir: string,
|
|
133
|
+
options: BeginChangeOptions = {}
|
|
134
|
+
): Promise<ChangeRecord> {
|
|
135
|
+
// 이미 활성 트랜잭션이 있는지 확인
|
|
136
|
+
const existingState = await readActiveState(rootDir);
|
|
137
|
+
if (existingState.active) {
|
|
138
|
+
const existingChange = await getActiveTransaction(rootDir);
|
|
139
|
+
throw new Error(
|
|
140
|
+
`이미 활성화된 트랜잭션이 있습니다: ${existingState.changeId}${existingChange?.message ? ` (${existingChange.message})` : ""}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 스냅샷 생성
|
|
145
|
+
const snapshot = await createSnapshot(rootDir);
|
|
146
|
+
await writeSnapshot(rootDir, snapshot);
|
|
147
|
+
|
|
148
|
+
// 변경 기록 생성
|
|
149
|
+
const changeId = generateChangeId();
|
|
150
|
+
const change: ChangeRecord = {
|
|
151
|
+
id: changeId,
|
|
152
|
+
message: options.message,
|
|
153
|
+
status: "active",
|
|
154
|
+
createdAt: new Date().toISOString(),
|
|
155
|
+
snapshotId: snapshot.id,
|
|
156
|
+
autoGenerated: options.autoGenerated,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// 변경 기록 추가
|
|
160
|
+
const changes = await readChanges(rootDir);
|
|
161
|
+
changes.push(change);
|
|
162
|
+
await writeChanges(rootDir, changes);
|
|
163
|
+
|
|
164
|
+
// 활성 상태 업데이트
|
|
165
|
+
const activeState: TransactionState = {
|
|
166
|
+
active: true,
|
|
167
|
+
changeId: change.id,
|
|
168
|
+
snapshotId: snapshot.id,
|
|
169
|
+
};
|
|
170
|
+
await writeActiveState(rootDir, activeState);
|
|
171
|
+
|
|
172
|
+
return change;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 변경 트랜잭션 커밋
|
|
177
|
+
* - 현재 상태를 확정하고 트랜잭션 종료
|
|
178
|
+
*/
|
|
179
|
+
export async function commitChange(rootDir: string, changeId?: string): Promise<CommitResult> {
|
|
180
|
+
const state = await readActiveState(rootDir);
|
|
181
|
+
|
|
182
|
+
if (!state.active) {
|
|
183
|
+
throw new Error("활성화된 트랜잭션이 없습니다");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const targetChangeId = changeId || state.changeId;
|
|
187
|
+
if (!targetChangeId) {
|
|
188
|
+
throw new Error("커밋할 변경 ID를 찾을 수 없습니다");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (changeId && state.changeId !== changeId) {
|
|
192
|
+
throw new Error(`지정된 변경(${changeId})이 현재 활성 트랜잭션(${state.changeId})과 다릅니다`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 변경 기록 업데이트
|
|
196
|
+
const changes = await readChanges(rootDir);
|
|
197
|
+
const changeIndex = changes.findIndex((c) => c.id === targetChangeId);
|
|
198
|
+
|
|
199
|
+
if (changeIndex === -1) {
|
|
200
|
+
throw new Error(`변경 기록을 찾을 수 없습니다: ${targetChangeId}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const change = changes[changeIndex];
|
|
204
|
+
change.status = "committed";
|
|
205
|
+
await writeChanges(rootDir, changes);
|
|
206
|
+
|
|
207
|
+
// 활성 상태 초기화
|
|
208
|
+
await writeActiveState(rootDir, {
|
|
209
|
+
active: false,
|
|
210
|
+
changeId: null,
|
|
211
|
+
snapshotId: null,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
success: true,
|
|
216
|
+
changeId: targetChangeId,
|
|
217
|
+
message: change.message,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 변경 트랜잭션 롤백
|
|
223
|
+
* - 스냅샷으로 복원하고 트랜잭션 종료
|
|
224
|
+
*/
|
|
225
|
+
export async function rollbackChange(rootDir: string, changeId?: string): Promise<RollbackResult> {
|
|
226
|
+
const state = await readActiveState(rootDir);
|
|
227
|
+
|
|
228
|
+
// 활성 트랜잭션이 없으면 changeId로 찾기
|
|
229
|
+
let targetChangeId: string;
|
|
230
|
+
let targetSnapshotId: string;
|
|
231
|
+
|
|
232
|
+
if (state.active && state.changeId) {
|
|
233
|
+
targetChangeId = changeId || state.changeId;
|
|
234
|
+
if (changeId && state.changeId !== changeId) {
|
|
235
|
+
throw new Error(`지정된 변경(${changeId})이 현재 활성 트랜잭션(${state.changeId})과 다릅니다`);
|
|
236
|
+
}
|
|
237
|
+
targetSnapshotId = state.snapshotId!;
|
|
238
|
+
} else if (changeId) {
|
|
239
|
+
// 활성 트랜잭션이 없지만 changeId가 지정된 경우
|
|
240
|
+
const changes = await readChanges(rootDir);
|
|
241
|
+
const change = changes.find((c) => c.id === changeId);
|
|
242
|
+
if (!change) {
|
|
243
|
+
throw new Error(`변경 기록을 찾을 수 없습니다: ${changeId}`);
|
|
244
|
+
}
|
|
245
|
+
targetChangeId = changeId;
|
|
246
|
+
targetSnapshotId = change.snapshotId;
|
|
247
|
+
} else {
|
|
248
|
+
throw new Error("롤백할 트랜잭션이 없습니다");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 스냅샷 읽기
|
|
252
|
+
const snapshot = await readSnapshotById(rootDir, targetSnapshotId);
|
|
253
|
+
if (!snapshot) {
|
|
254
|
+
throw new Error(`스냅샷을 찾을 수 없습니다: ${targetSnapshotId}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 스냅샷으로 복원
|
|
258
|
+
const restoreResult = await restoreSnapshot(rootDir, snapshot);
|
|
259
|
+
|
|
260
|
+
// 변경 기록 업데이트
|
|
261
|
+
const changes = await readChanges(rootDir);
|
|
262
|
+
const changeIndex = changes.findIndex((c) => c.id === targetChangeId);
|
|
263
|
+
|
|
264
|
+
if (changeIndex !== -1) {
|
|
265
|
+
changes[changeIndex].status = "rolled_back";
|
|
266
|
+
await writeChanges(rootDir, changes);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 활성 상태 초기화
|
|
270
|
+
await writeActiveState(rootDir, {
|
|
271
|
+
active: false,
|
|
272
|
+
changeId: null,
|
|
273
|
+
snapshotId: null,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
success: restoreResult.success,
|
|
278
|
+
changeId: targetChangeId,
|
|
279
|
+
restoreResult,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 트랜잭션 상태 조회
|
|
285
|
+
*/
|
|
286
|
+
export async function getTransactionStatus(
|
|
287
|
+
rootDir: string
|
|
288
|
+
): Promise<{ state: TransactionState; change: ChangeRecord | null }> {
|
|
289
|
+
const state = await readActiveState(rootDir);
|
|
290
|
+
const change = state.active ? await getActiveTransaction(rootDir) : null;
|
|
291
|
+
|
|
292
|
+
return { state, change };
|
|
293
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
2
|
+
import type { SpecLock } from "../spec/lock";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 변경 기록 - 하나의 트랜잭션을 나타냄
|
|
6
|
+
*/
|
|
7
|
+
export interface ChangeRecord {
|
|
8
|
+
/** 고유 ID: YYYYMMDD-HHmmss-xxx 형식 */
|
|
9
|
+
id: string;
|
|
10
|
+
/** 변경 설명 (선택) */
|
|
11
|
+
message?: string;
|
|
12
|
+
/** 상태: active(진행중), committed(확정), rolled_back(롤백됨) */
|
|
13
|
+
status: "active" | "committed" | "rolled_back";
|
|
14
|
+
/** 생성 시각 ISO 문자열 */
|
|
15
|
+
createdAt: string;
|
|
16
|
+
/** 연결된 스냅샷 ID */
|
|
17
|
+
snapshotId: string;
|
|
18
|
+
/** Auto-correct에 의해 생성됨 여부 */
|
|
19
|
+
autoGenerated?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 스냅샷 - 특정 시점의 spec 상태를 저장
|
|
24
|
+
*/
|
|
25
|
+
export interface Snapshot {
|
|
26
|
+
/** 고유 ID */
|
|
27
|
+
id: string;
|
|
28
|
+
/** 생성 시각 ISO 문자열 */
|
|
29
|
+
timestamp: string;
|
|
30
|
+
/** routes.manifest.json 내용 (~1KB) */
|
|
31
|
+
manifest: RoutesManifest;
|
|
32
|
+
/** spec.lock.json 내용 (~0.1KB, 없을 수 있음) */
|
|
33
|
+
lock: SpecLock | null;
|
|
34
|
+
/** Slot 파일 내용만 저장 (Generated 파일은 재생성 가능) */
|
|
35
|
+
slotContents: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 현재 트랜잭션 상태
|
|
40
|
+
*/
|
|
41
|
+
export interface TransactionState {
|
|
42
|
+
/** 트랜잭션 활성화 여부 */
|
|
43
|
+
active: boolean;
|
|
44
|
+
/** 현재 활성 변경 ID */
|
|
45
|
+
changeId: string | null;
|
|
46
|
+
/** 현재 활성 스냅샷 ID */
|
|
47
|
+
snapshotId: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* History 설정
|
|
52
|
+
*/
|
|
53
|
+
export interface HistoryConfig {
|
|
54
|
+
/** 최대 보관 스냅샷 수 (기본: 5) */
|
|
55
|
+
maxSnapshots: number;
|
|
56
|
+
/** 자동 정리 활성화 (기본: true) */
|
|
57
|
+
autoCleanup: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 스냅샷 복원 결과
|
|
62
|
+
*/
|
|
63
|
+
export interface RestoreResult {
|
|
64
|
+
success: boolean;
|
|
65
|
+
restoredFiles: string[];
|
|
66
|
+
failedFiles: string[];
|
|
67
|
+
errors: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 커밋 결과
|
|
72
|
+
*/
|
|
73
|
+
export interface CommitResult {
|
|
74
|
+
success: boolean;
|
|
75
|
+
changeId: string;
|
|
76
|
+
message?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 롤백 결과
|
|
81
|
+
*/
|
|
82
|
+
export interface RollbackResult {
|
|
83
|
+
success: boolean;
|
|
84
|
+
changeId: string;
|
|
85
|
+
restoreResult: RestoreResult;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* beginChange 옵션
|
|
90
|
+
*/
|
|
91
|
+
export interface BeginChangeOptions {
|
|
92
|
+
message?: string;
|
|
93
|
+
autoGenerated?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 기본 History 설정
|
|
98
|
+
*/
|
|
99
|
+
export const DEFAULT_HISTORY_CONFIG: HistoryConfig = {
|
|
100
|
+
maxSnapshots: 5,
|
|
101
|
+
autoCleanup: true,
|
|
102
|
+
};
|