session-file.ts
10671 bytes
1import { randomUUID } from "node:crypto";
2import { chmod, link, lstat, open, readdir, readFile, rename, unlink } from "node:fs/promises";
3import { basename, dirname, join, resolve } from "node:path";
4import {
5 toEntryId,
6 treeEntryFromUnknown,
7 type BranchCandidate,
8 type EntryId,
9 type TreeEntry,
10} from "./tree-model.ts";
11
12declare const sessionFilePathBrand: unique symbol;
13
14export type SessionFilePath = string & { readonly [sessionFilePathBrand]: "SessionFilePath" };
15
16export interface FileIdentity {
17 readonly dev: number;
18 readonly ino: number;
19 readonly mode: number;
20 readonly mtimeMs: number;
21 readonly size: number;
22}
23
24export interface SessionFileSnapshot {
25 readonly path: SessionFilePath;
26 readonly content: string;
27 readonly identity: FileIdentity;
28}
29
30interface RawLine {
31 readonly content: string;
32 readonly record?: Record<string, unknown>;
33}
34
35export function toSessionFilePath(path: string): SessionFilePath {
36 return resolve(path) as SessionFilePath;
37}
38
39export async function captureSessionFile(sessionFile: string): Promise<SessionFileSnapshot> {
40 const path = toSessionFilePath(sessionFile);
41
42 for (let attempt = 0; attempt < 2; attempt += 1) {
43 const before = await getFileIdentity(path);
44 const content = await readFile(path, "utf8");
45 const after = await getFileIdentity(path);
46
47 if (sameIdentity(before, after) && Buffer.byteLength(content) === after.size) {
48 return { path, content, identity: after };
49 }
50 }
51
52 throw new Error("Session file changed while it was read");
53}
54
55export async function assertSessionFileUnchanged(snapshot: SessionFileSnapshot): Promise<void> {
56 const current = await captureSessionFile(snapshot.path);
57 if (!sameIdentity(snapshot.identity, current.identity) || snapshot.content !== current.content) {
58 throw new Error("Session file changed while the dialog was open");
59 }
60}
61
62export function getSessionTreeEntries(snapshot: SessionFileSnapshot): TreeEntry[] {
63 const entries: TreeEntry[] = [];
64 for (const rawLine of splitRawLines(snapshot.content)) {
65 const entry = treeEntryFromUnknown(rawLine.record);
66 if (entry) entries.push(entry);
67 }
68 return entries;
69}
70
71export function buildPrunedSessionContent(
72 snapshot: SessionFileSnapshot,
73 candidates: readonly BranchCandidate[],
74): string {
75 const expectedEntries = collectPruneEntries(candidates);
76 const rawLines = splitRawLines(snapshot.content);
77 const linesByEntryId = new Map<EntryId, number[]>();
78
79 for (let index = 0; index < rawLines.length; index += 1) {
80 const rawLine = rawLines[index];
81 if (!rawLine?.record) continue;
82
83 const id = rawLine.record.id;
84 if (typeof id !== "string" || id.length === 0) continue;
85
86 const entryId = toEntryId(id);
87 if (!expectedEntries.has(entryId)) continue;
88
89 const indexes = linesByEntryId.get(entryId) ?? [];
90 indexes.push(index);
91 linesByEntryId.set(entryId, indexes);
92 }
93
94 const lineIndexesToRemove = new Set<number>();
95 for (const [id, entry] of expectedEntries) {
96 const indexes = linesByEntryId.get(id) ?? [];
97 if (indexes.length !== 1) {
98 throw new Error(`Could not safely locate exactly one JSONL line for entry ${id}`);
99 }
100
101 const lineIndex = indexes[0];
102 if (lineIndex === undefined) {
103 throw new Error(`Could not safely locate JSONL line for entry ${id}`);
104 }
105
106 const rawLine = rawLines[lineIndex];
107 if (!rawLine?.record || !hasSameEntryIdentity(entryIdentityFromRecord(rawLine.record), entry)) {
108 throw new Error(`JSONL line for entry ${id} does not match the loaded session`);
109 }
110
111 lineIndexesToRemove.add(lineIndex);
112 }
113
114 return rawLines
115 .filter((_line, index) => !lineIndexesToRemove.has(index))
116 .map((line) => line.content)
117 .join("");
118}
119
120export function createBackupPath(sessionFile: SessionFilePath): SessionFilePath {
121 const path = toSessionFilePath(sessionFile);
122 const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
123 return toSessionFilePath(
124 join(dirname(path), `${basename(path)}.tree-prune-backup-${timestamp}-${randomUUID()}`),
125 );
126}
127
128export async function createSessionBackup(
129 snapshot: SessionFileSnapshot,
130 backupPath = createBackupPath(snapshot.path),
131): Promise<SessionFilePath> {
132 const path = toSessionFilePath(backupPath);
133 if (dirname(path) !== dirname(snapshot.path)) {
134 throw new Error("Backup must be in the session file directory");
135 }
136
137 await writeNewFileAtomically(path, snapshot.content, snapshot.identity.mode);
138 return path;
139}
140
141export function countPruneEntries(candidates: readonly BranchCandidate[]): number {
142 return collectPruneEntries(candidates).size;
143}
144
145export async function pruneSessionFile(
146 snapshot: SessionFileSnapshot,
147 candidates: readonly BranchCandidate[],
148 backupPath = createBackupPath(snapshot.path),
149): Promise<{ backupPath: SessionFilePath; removedEntryCount: number }> {
150 const removedEntryCount = countPruneEntries(candidates);
151 const prunedContent = buildPrunedSessionContent(snapshot, candidates);
152 await assertSessionFileUnchanged(snapshot);
153 const createdBackupPath = await createSessionBackup(snapshot, backupPath);
154 await replaceSessionFile(snapshot, prunedContent);
155
156 return { backupPath: createdBackupPath, removedEntryCount };
157}
158
159export async function restoreSessionBackup(
160 currentSnapshot: SessionFileSnapshot,
161 selectedBackup: SessionFileSnapshot,
162 backupPath = createBackupPath(currentSnapshot.path),
163): Promise<SessionFilePath> {
164 if (dirname(selectedBackup.path) !== dirname(currentSnapshot.path)) {
165 throw new Error("Backup must be in the session file directory");
166 }
167
168 await assertSessionFileUnchanged(currentSnapshot);
169 await assertSessionFileUnchanged(selectedBackup);
170 const currentBackupPath = await createSessionBackup(currentSnapshot, backupPath);
171 await replaceSessionFile(currentSnapshot, selectedBackup.content);
172
173 return currentBackupPath;
174}
175
176export async function listSessionBackups(sessionFile: SessionFilePath): Promise<SessionFilePath[]> {
177 const path = toSessionFilePath(sessionFile);
178 const directory = dirname(path);
179 const prefix = `${basename(path)}.tree-prune-backup-`;
180 const directoryEntries = await readdir(directory, { withFileTypes: true });
181
182 return directoryEntries
183 .filter((entry) => entry.isFile() && entry.name.startsWith(prefix))
184 .map((entry) => toSessionFilePath(join(directory, entry.name)))
185 .sort((left, right) => right.localeCompare(left));
186}
187
188async function replaceSessionFile(snapshot: SessionFileSnapshot, content: string): Promise<void> {
189 const temporaryPath = temporaryPathFor(snapshot.path);
190
191 try {
192 await writeFileAndSync(temporaryPath, content, snapshot.identity.mode);
193 await assertSessionFileUnchanged(snapshot);
194 await rename(temporaryPath, snapshot.path);
195 } catch (error) {
196 await removeIfPresent(temporaryPath);
197 throw error;
198 }
199}
200
201async function writeNewFileAtomically(path: string, content: string, mode: number): Promise<void> {
202 const temporaryPath = temporaryPathFor(path);
203
204 try {
205 await writeFileAndSync(temporaryPath, content, mode);
206 await link(temporaryPath, path);
207 } finally {
208 await removeIfPresent(temporaryPath);
209 }
210}
211
212async function writeFileAndSync(path: string, content: string, mode: number): Promise<void> {
213 const file = await open(path, "wx", mode);
214 try {
215 await file.writeFile(content, "utf8");
216 await file.sync();
217 } finally {
218 await file.close();
219 }
220
221 await chmod(path, mode);
222}
223
224async function removeIfPresent(path: string): Promise<void> {
225 try {
226 await unlink(path);
227 } catch (error) {
228 if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
229 }
230}
231
232async function getFileIdentity(path: string): Promise<FileIdentity> {
233 const stats = await lstat(path);
234 if (!stats.isFile() || stats.isSymbolicLink()) {
235 throw new Error("Session file must be a regular file");
236 }
237
238 return {
239 dev: stats.dev,
240 ino: stats.ino,
241 mode: stats.mode,
242 mtimeMs: stats.mtimeMs,
243 size: stats.size,
244 };
245}
246
247function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
248 return (
249 left.dev === right.dev &&
250 left.ino === right.ino &&
251 left.mode === right.mode &&
252 left.mtimeMs === right.mtimeMs &&
253 left.size === right.size
254 );
255}
256
257function splitRawLines(content: string): RawLine[] {
258 const lines: RawLine[] = [];
259 let start = 0;
260
261 while (start < content.length) {
262 const lineEnd = content.indexOf("\n", start);
263 const end = lineEnd === -1 ? content.length : lineEnd + 1;
264 const line = content.slice(start, end);
265 const record = parseRecord(line);
266 lines.push(record ? { content: line, record } : { content: line });
267 start = end;
268 }
269
270 return lines;
271}
272
273function parseRecord(line: string): Record<string, unknown> | undefined {
274 const source = line.endsWith("\n") ? line.slice(0, -1) : line;
275 if (source.trim().length === 0) return undefined;
276
277 try {
278 const parsed: unknown = JSON.parse(source);
279 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
280 return parsed as Record<string, unknown>;
281 } catch {
282 return undefined;
283 }
284}
285
286function collectPruneEntries(candidates: readonly BranchCandidate[]): Map<EntryId, TreeEntry> {
287 const entriesById = new Map<EntryId, TreeEntry>();
288
289 for (const candidate of candidates) {
290 for (const entry of candidate.entries) {
291 const existingEntry = entriesById.get(entry.id);
292 if (existingEntry && !hasSameEntryIdentity(existingEntry, entry)) {
293 throw new Error(`Selected branches disagree about entry ${entry.id}`);
294 }
295 entriesById.set(entry.id, entry);
296 }
297 }
298
299 if (entriesById.size === 0) throw new Error("Select at least one branch");
300 return entriesById;
301}
302
303interface EntryIdentity {
304 readonly id: EntryId;
305 readonly parentId: EntryId | null;
306 readonly type: string;
307}
308
309function entryIdentityFromRecord(record: Record<string, unknown>): EntryIdentity | undefined {
310 const { id, parentId, type } = record;
311 if (
312 typeof id !== "string" ||
313 id.length === 0 ||
314 (typeof parentId !== "string" && parentId !== null) ||
315 typeof type !== "string" ||
316 type.length === 0
317 ) {
318 return undefined;
319 }
320
321 return {
322 id: id as EntryId,
323 parentId: parentId as EntryId | null,
324 type,
325 };
326}
327
328function hasSameEntryIdentity(
329 left: EntryIdentity | undefined,
330 right: EntryIdentity,
331): boolean {
332 if (!left) return false;
333 return left.id === right.id && left.parentId === right.parentId && left.type === right.type;
334}
335
336function temporaryPathFor(path: string): string {
337 return join(dirname(path), `.${basename(path)}.tree-prune-tmp-${process.pid}-${randomUUID()}`);
338}